diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..658f75d1 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,79 @@ +version: 2.1 + +executors: + java17: + docker: + - image: 'cimg/openjdk:17.0' + resource_class: small + +orbs: + slack: circleci/slack@4.10.1 + +commands: + run_maven: + parameters: + maven_subcommand: + default: test + type: string + steps: + - checkout + - restore_cache: + keys: + - v1-dependencies-{{ checksum "pom.xml" }} + - run: + name: Run Maven + command: | + mvn -s .circleci/mvn-settings.xml << parameters.maven_subcommand >> + - store_artifacts: + path: target/site/jacoco + - run: + name: Save test results + command: | + mkdir -p ~/test-results/junit/ + find . -type f -regex ".*/target/surefire-reports/.*xml" -exec cp {} ~/test-results/junit/ \; + when: always + - store_test_results: + path: ~/test-results + - save_cache: + paths: + - ~/.m2 + key: v1-dependencies-{{ checksum "pom.xml" }} + +jobs: + mvn_test: + executor: java17 + steps: + - run_maven: + maven_subcommand: verify + - slack/notify: + event: fail + + mvn_deploy: + executor: java17 + steps: + - run_maven: + maven_subcommand: deploy + - slack/notify: + event: always + +workflows: + test_only: + jobs: + - mvn_test: + context: [ qqq-maven-registry-credentials, kingsrook-slack ] + filters: + branches: + ignore: /dev/ + tags: + ignore: /(version|snapshot)-.*/ + + deploy: + jobs: + - mvn_deploy: + context: [ qqq-maven-registry-credentials, kingsrook-slack ] + filters: + branches: + only: /dev/ + tags: + only: /(version|snapshot)-.*/ + diff --git a/.circleci/mvn-settings.xml b/.circleci/mvn-settings.xml new file mode 100644 index 00000000..b2a345f0 --- /dev/null +++ b/.circleci/mvn-settings.xml @@ -0,0 +1,9 @@ + + + + github-qqq-maven-registry + ${env.QQQ_MAVEN_REGISTRY_USERNAME} + ${env.QQQ_MAVEN_REGISTRY_PASSWORD} + + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2c7054c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +target/ +*.iml +.env + + +############################################# +## Original contents from github template: ## +############################################# +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..ca9b0551 --- /dev/null +++ b/LICENSE @@ -0,0 +1,619 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 00000000..672d87d7 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# qqq-middleware-picocli + +This is a qqq middleware module, providing [picocli](https://picocli.info) access to the qqq-backend. + +## License +QQQ - Low-code Application Framework for Engineers. \ +Copyright (C) 2022. Kingsrook, LLC \ +651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States \ +contact@kingsrook.com | https://github.com/Kingsrook/ + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 00000000..f5e7412d --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..07414d4f --- /dev/null +++ b/pom.xml @@ -0,0 +1,293 @@ + + + + + 4.0.0 + + com.kingsrook.qqq + qqq-middleware-picocli + 0.3.0-SNAPSHOT + + + scm:git:git@github.com:Kingsrook/qqq-middleware-picocli.git + scm:git:git@github.com:Kingsrook/qqq-middleware-picocli.git + HEAD + + + + + + + + UTF-8 + UTF-8 + 17 + 17 + true + true + true + 0.80 + + + + + + com.kingsrook.qqq + qqq-backend-core + 0.2.0 + + + com.kingsrook.qqq + qqq-backend-module-rdbms + 0.2.0 + test + + + + + + info.picocli + picocli + 4.6.1 + + + info.picocli + picocli-shell-jline3 + 4.6.3 + + + com.h2database + h2 + 2.1.210 + test + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.2 + + + org.apache.logging.log4j + log4j-api + 2.17.1 + + + org.apache.logging.log4j + log4j-core + 2.17.1 + + + org.junit.jupiter + junit-jupiter-engine + 5.8.1 + test + + + org.assertj + assertj-core + 3.23.1 + test + + + + + + + + maven-assembly-plugin + + + jar-with-dependencies + + + + com.kingsrook.qqq.frontend.picocli.QPicoCliImplementation + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + -Xlint:unchecked + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + @{jaCoCoArgLine} + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.1.2 + + + com.puppycrawl.tools + checkstyle + 9.0 + + + + + validate + validate + + checkstyle.xml + + UTF-8 + true + false + true + warning + **/target/generated-sources/*.* + + + + check + + + + + + com.amashchenko.maven.plugin + gitflow-maven-plugin + 1.18.0 + + + main + dev + version- + + true + install + true + 1 + + + + org.jacoco + jacoco-maven-plugin + 0.8.8 + + + pre-unit-test + + prepare-agent + + + jaCoCoArgLine + + + + unit-test-check + + check + + + + ${coverage.haltOnFailure} + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + ${coverage.instructionCoveredRatioMinimum} + + + + + + + + post-unit-test + verify + + report + + + + + + exec-maven-plugin + org.codehaus.mojo + 3.0.0 + + + test-coverage-summary + verify + + exec + + + sh + + -c + + /tmp/$$.headers +xpath -q -e '/html/body/table/tfoot/tr[1]/td/text()' target/site/jacoco/index.html > /tmp/$$.values +echo +echo "Jacoco coverage summary report:" +echo " See also target/site/jacoco/index.html" +echo " and https://www.jacoco.org/jacoco/trunk/doc/counters.html" +echo "------------------------------------------------------------" +paste /tmp/$$.headers /tmp/$$.values | tail +2 | awk -v FS='\t' '{printf("%-20s %s\n",$1,$2)}' +rm /tmp/$$.headers /tmp/$$.values + ]]> + + + + + + + + + + + + github-qqq-maven-registry + GitHub QQQ Maven Registry + https://maven.pkg.github.com/Kingsrook/qqq-maven-registry + + + + + + github-qqq-maven-registry + GitHub QQQ Maven Registry + https://maven.pkg.github.com/Kingsrook/qqq-maven-registry + + + + diff --git a/src/main/java/com/kingsrook/qqq/frontend/picocli/PicoCliProcessCallback.java b/src/main/java/com/kingsrook/qqq/frontend/picocli/PicoCliProcessCallback.java new file mode 100644 index 00000000..9d6f4555 --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/picocli/PicoCliProcessCallback.java @@ -0,0 +1,87 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.picocli; + + +import java.io.Serializable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Scanner; +import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import picocli.CommandLine; + + +/******************************************************************************* + ** Define how a PicoCLI process gets data back to a QProcess. + *******************************************************************************/ +public class PicoCliProcessCallback implements QProcessCallback +{ + private final CommandLine commandLine; + + + + /******************************************************************************* + ** Constructor that takes the picocli CommandLine object + *******************************************************************************/ + public PicoCliProcessCallback(CommandLine commandLine) + { + this.commandLine = commandLine; + } + + + + /******************************************************************************* + ** Get the filter query for this callback. + *******************************************************************************/ + @Override + public QQueryFilter getQueryFilter() + { + return null; + } + + + + /******************************************************************************* + ** Get the field values for this callback. + *******************************************************************************/ + @Override + public Map getFieldValues(List fields) + { + Map rs = new HashMap<>(); + final Scanner scanner = new Scanner(System.in); + + /////////////////////////////////// + // todo - only if "interactive?" // + /////////////////////////////////// + for(QFieldMetaData field : fields) + { + commandLine.getOut().println("Please supply a value for the field: [" + field.getLabel() + "]:"); + rs.put(field.getName(), scanner.nextLine()); + } + + return (rs); + } + +} diff --git a/src/main/java/com/kingsrook/qqq/frontend/picocli/QCommandBuilder.java b/src/main/java/com/kingsrook/qqq/frontend/picocli/QCommandBuilder.java new file mode 100644 index 00000000..505f1340 --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/picocli/QCommandBuilder.java @@ -0,0 +1,417 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.picocli; + + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import picocli.CommandLine; + + +/******************************************************************************* + ** Helper class for QPicCliImplementation to build the Command + ** + *******************************************************************************/ +public class QCommandBuilder +{ + private final QInstance qInstance; + + + + /******************************************************************************* + ** Constructor. + ** + *******************************************************************************/ + public QCommandBuilder(QInstance qInstance) + { + this.qInstance = qInstance; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + CommandLine.Model.CommandSpec buildCommandSpec(String topCommandName) + { + ////////////////////////////////// + // define the top-level command // + ////////////////////////////////// + CommandLine.Model.CommandSpec topCommandSpec = CommandLine.Model.CommandSpec.create(); + topCommandSpec.name(topCommandName); + topCommandSpec.version(topCommandName + " v1.0"); // todo... uh? + topCommandSpec.mixinStandardHelpOptions(true); // usageHelp and versionHelp options + topCommandSpec.addOption(CommandLine.Model.OptionSpec.builder("-m", "--meta-data") + .type(boolean.class) + .description("Output the meta-data for this CLI") + .build()); + + ///////////////////////////////////// + // add each table as a sub-command // + ///////////////////////////////////// + qInstance.getTables().keySet().stream().sorted().forEach(tableName -> + { + QTableMetaData table = qInstance.getTable(tableName); + + CommandLine.Model.CommandSpec tableCommand = CommandLine.Model.CommandSpec.create(); + topCommandSpec.addSubcommand(table.getName(), tableCommand); + + /////////////////////////////////////////////////// + // add table-specific sub-commands for the table // + /////////////////////////////////////////////////// + tableCommand.addSubcommand("meta-data", defineMetaDataCommand(table)); + tableCommand.addSubcommand("count", defineCountCommand(table)); + tableCommand.addSubcommand("get", defineGetCommand(table)); + tableCommand.addSubcommand("query", defineQueryCommand(table)); + tableCommand.addSubcommand("insert", defineInsertCommand(table)); + tableCommand.addSubcommand("update", defineUpdateCommand(table)); + tableCommand.addSubcommand("delete", defineDeleteCommand(table)); + tableCommand.addSubcommand("export", defineExportCommand(table)); + + List processes = qInstance.getProcessesForTable(tableName); + if(CollectionUtils.nullSafeHasContents(processes)) + { + tableCommand.addSubcommand("process", defineProcessesCommand(processes)); + } + }); + + /////////////////////////////////////////////////////////////////////////// + // add all orphan processes (e.g., ones without tables) to the top-level // + /////////////////////////////////////////////////////////////////////////// + List orphanProcesses = new ArrayList<>(); + for(QProcessMetaData process : qInstance.getProcesses().values()) + { + if(!StringUtils.hasContent(process.getTableName())) + { + orphanProcesses.add(process); + } + } + + if(!orphanProcesses.isEmpty()) + { + topCommandSpec.addSubcommand("processes", defineProcessesCommand(orphanProcesses)); + } + + return topCommandSpec; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private CommandLine.Model.CommandSpec defineMetaDataCommand(QTableMetaData table) + { + return CommandLine.Model.CommandSpec.create(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private CommandLine.Model.CommandSpec defineQueryCommand(QTableMetaData table) + { + CommandLine.Model.CommandSpec queryCommand = CommandLine.Model.CommandSpec.create(); + queryCommand.addOption(CommandLine.Model.OptionSpec.builder("-l", "--limit") + .type(int.class) + .build()); + queryCommand.addOption(CommandLine.Model.OptionSpec.builder("-s", "--skip") + .type(int.class) + .build()); + queryCommand.addOption(CommandLine.Model.OptionSpec.builder("-c", "--criteria") + .type(String[].class) + .build()); + + // todo - add the fields as explicit params? + + return queryCommand; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private CommandLine.Model.CommandSpec defineExportCommand(QTableMetaData table) + { + CommandLine.Model.CommandSpec exportCommand = CommandLine.Model.CommandSpec.create(); + exportCommand.addOption(CommandLine.Model.OptionSpec.builder("-f", "--filename") + .type(String.class) + .description("File name (including path) to write to. File extension will be used to determine the report format. Supported formats are: csv, xlsx.") + .required(true) + .build()); + exportCommand.addOption(CommandLine.Model.OptionSpec.builder("-e", "--fieldNames") + .type(String.class) + .description("Comma-separated list of field names (e.g., from table meta-data) to include in the export. If not given, then all fields in the table are included.") + .build()); + exportCommand.addOption(CommandLine.Model.OptionSpec.builder("-l", "--limit") + .type(int.class) + .description("Optional limit on the max number of records to include in the export.") + .build()); + addCriteriaOption(exportCommand); + + // todo - add the fields as explicit params? + + return exportCommand; + } + + + + /******************************************************************************* + ** add the standard '--criteria' option + *******************************************************************************/ + private void addCriteriaOption(CommandLine.Model.CommandSpec commandSpec) + { + commandSpec.addOption(CommandLine.Model.OptionSpec.builder("-c", "--criteria") + .type(String[].class) + .description(""" + Query filter criteria. May be given multiple times. + Use format: "$fieldName $operator $value". + e.g., "id EQUALS 42\"""") + .build()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void addPrimaryKeyOrKeysOption(CommandLine.Model.CommandSpec updateCommand, String verbForDescription) + { + updateCommand.addOption(CommandLine.Model.OptionSpec.builder("--primaryKey") + // type(getClassForField(primaryKeyField)) + .type(String.class) // todo - mmm, better as picocli's "compound" thing, w/ the actual pkey's type? + .description(""" + Primary Key(s) for the records to %s. + May provide multiple values, separated by commas""".formatted(verbForDescription)) + .build()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private CommandLine.Model.CommandSpec defineGetCommand(QTableMetaData table) + { + CommandLine.Model.CommandSpec getCommand = CommandLine.Model.CommandSpec.create(); + getCommand.addPositional(CommandLine.Model.PositionalParamSpec.builder() + .index("0") + // .type(String.class) // todo - mmm, better as picocli's "compound" thing, w/ the actual pkey's type? + .description("Primary key value from the table") + .build()); + + return getCommand; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private CommandLine.Model.CommandSpec defineCountCommand(QTableMetaData table) + { + CommandLine.Model.CommandSpec countCommand = CommandLine.Model.CommandSpec.create(); + addCriteriaOption(countCommand); + + // todo - add the fields as explicit params? + + return countCommand; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private CommandLine.Model.CommandSpec defineUpdateCommand(QTableMetaData table) + { + CommandLine.Model.CommandSpec updateCommand = CommandLine.Model.CommandSpec.create(); + + /* + todo - future may accept files, similar to (bulk) insert + updateCommand.addOption(CommandLine.Model.OptionSpec.builder("--jsonBody") + .type(String.class) + .build()); + + updateCommand.addOption(CommandLine.Model.OptionSpec.builder("--jsonFile") + .type(String.class) + .build()); + + updateCommand.addOption(CommandLine.Model.OptionSpec.builder("--csvFile") + .type(String.class) + .build()); + + updateCommand.addOption(CommandLine.Model.OptionSpec.builder("--mapping") + .type(String.class) + .build()); + */ + + QFieldMetaData primaryKeyField = null; + if(table.getPrimaryKeyField() != null) + { + primaryKeyField = table.getField(table.getPrimaryKeyField()); + addPrimaryKeyOrKeysOption(updateCommand, "update"); + } + + for(QFieldMetaData field : table.getFields().values()) + { + if(!field.equals(primaryKeyField)) + { + updateCommand.addOption(CommandLine.Model.OptionSpec.builder("--field-" + field.getName()) + .type(getClassForField(field)) + .description(""" + Value to set for the field %s""".formatted(field.getName())) + .build()); + } + } + + addCriteriaOption(updateCommand); + + return updateCommand; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private CommandLine.Model.CommandSpec defineInsertCommand(QTableMetaData table) + { + CommandLine.Model.CommandSpec insertCommand = CommandLine.Model.CommandSpec.create(); + + insertCommand.addOption(CommandLine.Model.OptionSpec.builder("--jsonBody") + .type(String.class) + .build()); + + insertCommand.addOption(CommandLine.Model.OptionSpec.builder("--jsonFile") + .type(String.class) + .build()); + + insertCommand.addOption(CommandLine.Model.OptionSpec.builder("--csvFile") + .type(String.class) + .build()); + + insertCommand.addOption(CommandLine.Model.OptionSpec.builder("--mapping") + .type(String.class) + .build()); + + for(QFieldMetaData field : table.getFields().values()) + { + insertCommand.addOption(CommandLine.Model.OptionSpec.builder("--field-" + field.getName()) + .type(getClassForField(field)) + .build()); + } + return insertCommand; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private CommandLine.Model.CommandSpec defineDeleteCommand(QTableMetaData table) + { + CommandLine.Model.CommandSpec deleteCommand = CommandLine.Model.CommandSpec.create(); + + deleteCommand.addOption(CommandLine.Model.OptionSpec.builder("--primaryKey") + .type(String.class) // todo - mmm, better as picocli's "compound" thing, w/ the actual pkey's type? + .build()); + + addCriteriaOption(deleteCommand); + + return deleteCommand; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private CommandLine.Model.CommandSpec defineProcessesCommand(List processes) + { + CommandLine.Model.CommandSpec processesCommand = CommandLine.Model.CommandSpec.create(); + + for(QProcessMetaData process : processes) + { + /////////////////////////////////////////// + // add the sub-command to run the proces // + /////////////////////////////////////////// + CommandLine.Model.CommandSpec processCommand = CommandLine.Model.CommandSpec.create(); + processesCommand.addSubcommand(process.getName(), processCommand); + + ////////////////////////////////////////////////////////////////////////////////// + // add all (distinct, by name) input fields to the command as --field-* options // + ////////////////////////////////////////////////////////////////////////////////// + Map inputFieldMap = new LinkedHashMap<>(); + for(QFieldMetaData inputField : process.getInputFields()) + { + inputFieldMap.put(inputField.getName(), inputField); + } + + for(QFieldMetaData field : inputFieldMap.values()) + { + processCommand.addOption(CommandLine.Model.OptionSpec.builder("--field-" + field.getName()) + .type(getClassForField(field)) + .build()); + } + + } + + return (processesCommand); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + @SuppressWarnings("checkstyle:Indentation") + private Class getClassForField(QFieldMetaData field) + { + // @formatter:off // IJ can't do new-style switch correctly yet... + return switch(field.getType()) + { + case STRING, TEXT, HTML, PASSWORD -> String.class; + case INTEGER -> Integer.class; + case DECIMAL -> BigDecimal.class; + case DATE -> LocalDate.class; + // case TIME -> LocalTime.class; + case DATE_TIME -> LocalDateTime.class; + case BLOB -> byte[].class; + }; + // @formatter:on + } + +} diff --git a/src/main/java/com/kingsrook/qqq/frontend/picocli/QPicoCliImplementation.java b/src/main/java/com/kingsrook/qqq/frontend/picocli/QPicoCliImplementation.java new file mode 100644 index 00000000..e83fe394 --- /dev/null +++ b/src/main/java/com/kingsrook/qqq/frontend/picocli/QPicoCliImplementation.java @@ -0,0 +1,951 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.picocli; + + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction; +import com.kingsrook.qqq.backend.core.actions.metadata.TableMetaDataAction; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.actions.reporting.ReportAction; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.adapters.CsvToQRecordAdapter; +import com.kingsrook.qqq.backend.core.adapters.JsonToQFieldMappingAdapter; +import com.kingsrook.qqq.backend.core.adapters.JsonToQRecordAdapter; +import com.kingsrook.qqq.backend.core.adapters.QInstanceAdapter; +import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QModuleDispatchException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; +import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput; +import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataOutput; +import com.kingsrook.qqq.backend.core.model.actions.metadata.TableMetaDataInput; +import com.kingsrook.qqq.backend.core.model.actions.metadata.TableMetaDataOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportFormat; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportInput; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ReportOutput; +import com.kingsrook.qqq.backend.core.model.actions.shared.mapping.AbstractQFieldMapping; +import com.kingsrook.qqq.backend.core.model.actions.shared.mapping.QKeyBasedFieldMapping; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.modules.authentication.Auth0AuthenticationModule; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleDispatcher; +import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleInterface; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import io.github.cdimascio.dotenv.Dotenv; +import org.apache.commons.io.FileUtils; +import org.apache.logging.log4j.core.config.Configurator; +import org.jline.reader.LineReader; +import org.jline.reader.LineReaderBuilder; +import org.jline.utils.Log; +import picocli.CommandLine; +import picocli.CommandLine.Model.CommandSpec; +import picocli.CommandLine.Model.OptionSpec; +import picocli.CommandLine.ParameterException; +import picocli.CommandLine.ParseResult; +import picocli.CommandLine.UnmatchedArgumentException; + + +/******************************************************************************* + ** QQQ PicoCLI implementation. Given a QInstance, produces an entire CLI + ** for working with all tables in that instance. + ** + ** Note: Please do not use System.out or .err here -- rather, use the CommandLine + ** object's out & err members - so the unit test can see the output! + ** + *******************************************************************************/ +public class QPicoCliImplementation +{ + public static final int DEFAULT_QUERY_LIMIT = 20; + + private static QInstance qInstance; + private static QSession session; + + + + /******************************************************************************* + ** + *******************************************************************************/ + public static void main(String[] args) throws IOException + { + // todo - authentication + // qInstance.addBackend(QMetaDataProvider.getQBackend()); + + // parse args to look up metaData and prime instance + if(args.length > 0 && args[0].startsWith("--qInstanceJsonFile=")) + { + String filePath = args[0].replaceFirst("--.*=", ""); + String qInstanceJson = FileUtils.readFileToString(new File(filePath)); + qInstance = new QInstanceAdapter().jsonToQInstanceIncludingBackends(qInstanceJson); + + String[] subArgs = Arrays.copyOfRange(args, 1, args.length); + + QPicoCliImplementation qPicoCliImplementation = new QPicoCliImplementation(qInstance); + int exitCode = qPicoCliImplementation.runCli("qapi", subArgs); + System.exit(exitCode); + } + else + { + System.err.println("To run this main class directly, you must specify: --qInstanceJsonFile=path/to/qInstance.json"); + System.exit(1); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + public QPicoCliImplementation(QInstance qInstance) + { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // use the qqq-picocli log4j config, less the system property log4j.configurationFile was set by the runner // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + if(System.getProperty("log4j.configurationFile") == null) + { + Configurator.initialize(null, "qqq-picocli-log4j2.xml"); + } + + QPicoCliImplementation.qInstance = qInstance; + } + + + + /******************************************************************************* + ** Driver method that uses System out & err streams. + * + *******************************************************************************/ + public int runCli(String name, String[] args) + { + return (runCli(name, args, System.out, System.err)); + } + + + + /******************************************************************************* + ** Actual driver methods that takes streams as params. + * + ** examples - todo, make docs complete! + ** my-app-cli [--all] [--format=] + ** my-app-cli $table meta-data [--format=] + ** my-app-cli $table query [--filterId=]|[--filter=]|[--criteria=...] + ** my-app-cli $table get (--primaryKey=|--$uc=...) + ** my-app-cli $table delete (--primaryKey=|--$uc=...) + ** my-app-cli $table insert (--body=|--$field=...) + ** my-app-cli $table update (--primaryKey=|--$uc=...) (--body=|--$field=...) + ** my-app-cli $table process $process ... + ** + *******************************************************************************/ + public int runCli(String name, String[] args, PrintStream out, PrintStream err) + { + CommandSpec topCommandSpec = new QCommandBuilder(qInstance).buildCommandSpec(name); + + CommandLine commandLine = new CommandLine(topCommandSpec); + commandLine.setOut(new PrintWriter(out, true)); + commandLine.setErr(new PrintWriter(err, true)); + + try + { + setupSession(args); + // todo - think about, do some tables get turned off based on authentication? + + ParseResult parseResult = commandLine.parseArgs(args); + + /////////////////////////////////////////// + // Did user request usage help (--help)? // + /////////////////////////////////////////// + if(commandLine.isUsageHelpRequested()) + { + commandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + //////////////////////////////////////////////// + // Did user request version help (--version)? // + //////////////////////////////////////////////// + else if(commandLine.isVersionHelpRequested()) + { + commandLine.printVersionHelp(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnVersionHelp(); + } + + /////////////////////////// + // else, run the command // + /////////////////////////// + return run(commandLine, parseResult); + } + catch(ParameterException ex) + { + ////////////////////////////////////////////////// + // handle command-line/param parsing exceptions // + ////////////////////////////////////////////////// + commandLine.getErr().println(ex.getMessage()); + UnmatchedArgumentException.printSuggestions(ex, commandLine.getErr()); + ex.getCommandLine().usage(commandLine.getErr()); + return commandLine.getCommandSpec().exitCodeOnInvalidInput(); + } + catch(Exception ex) + { + /////////////////////////////////////////// + // handle exceptions from business logic // + /////////////////////////////////////////// + ex.printStackTrace(); + commandLine.getErr().println("Error: " + ex.getMessage()); + return (commandLine.getCommandSpec().exitCodeOnExecutionException()); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static Optional loadDotEnv() + { + Optional dotenvOptional = Optional.empty(); + try + { + dotenvOptional = Optional.of(Dotenv.configure().load()); + } + catch(Exception e) + { + Log.info("No session information found in environment"); + } + + return (dotenvOptional); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private static void setupSession(String[] args) throws QModuleDispatchException, QAuthenticationException + { + QAuthenticationModuleDispatcher qAuthenticationModuleDispatcher = new QAuthenticationModuleDispatcher(); + QAuthenticationModuleInterface authenticationModule = qAuthenticationModuleDispatcher.getQModule(qInstance.getAuthentication()); + + try + { + //////////////////////////////////// + // look for .env environment file // + //////////////////////////////////// + String sessionId = null; + Optional dotenv = loadDotEnv(); + if(dotenv.isPresent()) + { + sessionId = dotenv.get().get("SESSION_ID"); + } + + Map authenticationContext = new HashMap<>(); + if(sessionId == null && authenticationModule instanceof Auth0AuthenticationModule) + { + LineReader lr = LineReaderBuilder.builder().build(); + String tokenId = lr.readLine("Create a .env file with the contents of the Auth0 JWT Id Token in the variable 'SESSION_ID': \nPress enter once complete..."); + dotenv = loadDotEnv(); + if(dotenv.isPresent()) + { + sessionId = dotenv.get().get("SESSION_ID"); + } + } + + authenticationContext.put("sessionId", sessionId); + + // todo - does this need some per-provider logic actually? mmm... + session = authenticationModule.createSession(qInstance, authenticationContext); + } + catch(QAuthenticationException qae) + { + throw (qae); + } + + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int run(CommandLine commandLine, ParseResult parseResult) throws QException + { + if(!parseResult.hasSubcommand()) + { + return runTopLevelCommand(commandLine, parseResult); + } + else + { + ParseResult subParseResult = parseResult.subcommand(); + String subCommandName = subParseResult.commandSpec().name(); + CommandLine subCommandLine = commandLine.getSubcommands().get(subCommandName); + switch(subCommandName) + { + case "processes": + { + return runProcessCommand(subCommandLine, subParseResult); + } + default: + { + ///////////////////////////////////////////////////////// + // by default, assume the command here is a table name // + ///////////////////////////////////////////////////////// + return runTableLevelCommand(subCommandLine, subParseResult); + } + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTableLevelCommand(CommandLine commandLine, ParseResult tableParseResult) throws QException + { + String tableName = tableParseResult.commandSpec().name(); + + if(tableParseResult.hasSubcommand()) + { + ParseResult subParseResult = tableParseResult.subcommand(); + String subCommandName = subParseResult.commandSpec().name(); + switch(subCommandName) + { + case "meta-data": + { + return runTableMetaData(commandLine, tableName, subParseResult); + } + case "count": + { + return runTableCount(commandLine, tableName, subParseResult); + } + case "get": + { + CommandLine subCommandLine = commandLine.getSubcommands().get(subCommandName); + return runTableGet(commandLine, tableName, subParseResult, subCommandLine); + } + case "query": + { + return runTableQuery(commandLine, tableName, subParseResult); + } + case "export": + { + return runTableExport(commandLine, tableName, subParseResult); + } + case "insert": + { + return runTableInsert(commandLine, tableName, subParseResult); + } + case "update": + { + return runTableUpdate(commandLine, tableName, subParseResult); + } + case "delete": + { + return runTableDelete(commandLine, tableName, subParseResult); + } + case "process": + { + CommandLine subCommandLine = commandLine.getSubcommands().get(subCommandName); + return runProcessCommand(subCommandLine, subParseResult); + } + default: + { + commandLine.getErr().println("Unknown command: " + subCommandName); + commandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + } + } + else + { + commandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + } + + + + /******************************************************************************* + ** Handle a command up to the point where 'process' was given + *******************************************************************************/ + private int runProcessCommand(CommandLine commandLine, ParseResult subParseResult) + { + if(!subParseResult.hasSubcommand()) + { + //////////////////////////////////////////////////////////////// + // process name must be a sub-command, so, error if not given // + //////////////////////////////////////////////////////////////// + commandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + else + { + /////////////////////////////////////////// + // move on to running the actual process // + /////////////////////////////////////////// + String subCommandName = subParseResult.subcommand().commandSpec().name(); + CommandLine subCommandLine = commandLine.getSubcommands().get(subCommandName); + return runActualProcess(subCommandLine, subParseResult.subcommand()); + } + } + + + + /******************************************************************************* + ** actually run a process (the process name should be at the start of the sub-command line) + *******************************************************************************/ + private int runActualProcess(CommandLine subCommandLine, ParseResult processParseResult) + { + String processName = processParseResult.commandSpec().name(); + QProcessMetaData process = qInstance.getProcess(processName); + RunProcessInput request = new RunProcessInput(qInstance); + + request.setSession(session); + request.setProcessName(processName); + request.setCallback(new PicoCliProcessCallback(subCommandLine)); + + for(OptionSpec matchedOption : processParseResult.matchedOptions()) + { + if(matchedOption.longestName().startsWith("--field-")) + { + String fieldName = matchedOption.longestName().substring(8); + request.addValue(fieldName, matchedOption.getValue()); + } + } + + try + { + RunProcessOutput result = new RunProcessAction().execute(request); + subCommandLine.getOut().println("Process Results: "); // todo better!! + for(QFieldMetaData outputField : process.getOutputFields()) + { + subCommandLine.getOut().format(" %s: %s\n", outputField.getLabel(), result.getValues().get(outputField.getName())); + } + + if(result.getException().isPresent()) + { + // todo - user-facing, similar to javalin + subCommandLine.getOut().println("Process Error message: " + result.getException().get().getMessage()); + } + } + catch(Exception e) + { + e.printStackTrace(); + subCommandLine.getOut().println("Caught Exception running process. See stack trace above for details."); + return 1; + } + + return 0; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTableMetaData(CommandLine commandLine, String tableName, ParseResult subParseResult) throws QException + { + TableMetaDataInput tableMetaDataInput = new TableMetaDataInput(qInstance); + tableMetaDataInput.setSession(session); + tableMetaDataInput.setTableName(tableName); + TableMetaDataAction tableMetaDataAction = new TableMetaDataAction(); + TableMetaDataOutput tableMetaDataOutput = tableMetaDataAction.execute(tableMetaDataInput); + commandLine.getOut().println(JsonUtils.toPrettyJson(tableMetaDataOutput)); + return commandLine.getCommandSpec().exitCodeOnSuccess(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTableCount(CommandLine commandLine, String tableName, ParseResult subParseResult) throws QException + { + CountInput countInput = new CountInput(qInstance); + countInput.setSession(session); + countInput.setTableName(tableName); + countInput.setFilter(generateQueryFilter(subParseResult)); + + CountAction countAction = new CountAction(); + CountOutput countOutput = countAction.execute(countInput); + commandLine.getOut().println(JsonUtils.toPrettyJson(countOutput)); + return commandLine.getCommandSpec().exitCodeOnSuccess(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTableGet(CommandLine commandLine, String tableName, ParseResult subParseResult, CommandLine subCommandLine) throws QException + { + QueryInput queryInput = new QueryInput(qInstance); + queryInput.setSession(session); + queryInput.setTableName(tableName); + queryInput.setSkip(subParseResult.matchedOptionValue("skip", null)); + String primaryKeyValue = subParseResult.matchedPositionalValue(0, null); + + if(primaryKeyValue == null) + { + subCommandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + + QTableMetaData table = queryInput.getTable(); + QQueryFilter filter = new QQueryFilter() + .withCriteria(new QFilterCriteria() + .withFieldName(table.getPrimaryKeyField()) + .withOperator(QCriteriaOperator.EQUALS) + .withValues(List.of(primaryKeyValue))); + queryInput.setFilter(filter); + + QueryAction queryAction = new QueryAction(); + QueryOutput queryOutput = queryAction.execute(queryInput); + List records = queryOutput.getRecords(); + if(records.isEmpty()) + { + commandLine.getOut().println("No " + table.getLabel() + " found for " + table.getField(table.getPrimaryKeyField()).getLabel() + ": " + primaryKeyValue); + return commandLine.getCommandSpec().exitCodeOnInvalidInput(); + } + else + { + commandLine.getOut().println(JsonUtils.toPrettyJson(records.get(0))); + return commandLine.getCommandSpec().exitCodeOnSuccess(); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTableQuery(CommandLine commandLine, String tableName, ParseResult subParseResult) throws QException + { + QueryInput queryInput = new QueryInput(qInstance); + queryInput.setSession(session); + queryInput.setTableName(tableName); + queryInput.setSkip(subParseResult.matchedOptionValue("skip", null)); + queryInput.setLimit(subParseResult.matchedOptionValue("limit", null)); + queryInput.setFilter(generateQueryFilter(subParseResult)); + + QueryAction queryAction = new QueryAction(); + QueryOutput queryOutput = queryAction.execute(queryInput); + commandLine.getOut().println(JsonUtils.toPrettyJson(queryOutput)); + return commandLine.getCommandSpec().exitCodeOnSuccess(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTableExport(CommandLine commandLine, String tableName, ParseResult subParseResult) throws QException + { + String filename = subParseResult.matchedOptionValue("--filename", ""); + + ///////////////////////////////////////////////////////////////////////////////////////// + // if a format query param wasn't given, then try to get file extension from file name // + ///////////////////////////////////////////////////////////////////////////////////////// + ReportFormat reportFormat; + if(filename.contains(".")) + { + reportFormat = ReportFormat.fromString(filename.substring(filename.lastIndexOf(".") + 1)); + } + else + { + throw (new QUserFacingException("File name did not contain an extension, so report format could not be inferred.")); + } + + OutputStream outputStream; + try + { + outputStream = new FileOutputStream(filename); + } + catch(Exception e) + { + throw (new QException("Error opening report file: " + e.getMessage(), e)); + } + + try + { + ///////////////////////////////////////////// + // set up the report action's input object // + ///////////////////////////////////////////// + ReportInput reportInput = new ReportInput(qInstance); + reportInput.setSession(session); + reportInput.setTableName(tableName); + reportInput.setReportFormat(reportFormat); + reportInput.setFilename(filename); + reportInput.setReportOutputStream(outputStream); + reportInput.setLimit(subParseResult.matchedOptionValue("limit", null)); + + reportInput.setQueryFilter(generateQueryFilter(subParseResult)); + + String fieldNames = subParseResult.matchedOptionValue("--fieldNames", ""); + if(StringUtils.hasContent(fieldNames)) + { + reportInput.setFieldNames(Arrays.asList(fieldNames.split(","))); + } + + ReportOutput reportOutput = new ReportAction().execute(reportInput); + + commandLine.getOut().println("Wrote " + reportOutput.getRecordCount() + " records to file " + filename); + return commandLine.getCommandSpec().exitCodeOnSuccess(); + } + finally + { + try + { + outputStream.close(); + } + catch(IOException e) + { + throw (new QException("Error closing report file", e)); + } + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private QQueryFilter generateQueryFilter(ParseResult subParseResult) + { + QQueryFilter filter = new QQueryFilter(); + + String[] criteria = subParseResult.matchedOptionValue("criteria", new String[] {}); + for(String criterion : criteria) + { + // todo - parse! + String[] parts = criterion.split(" "); + QFilterCriteria qQueryCriteria = new QFilterCriteria(); + qQueryCriteria.setFieldName(parts[0]); + qQueryCriteria.setOperator(QCriteriaOperator.valueOf(parts[1])); + qQueryCriteria.setValues(List.of(parts[2])); + filter.addCriteria(qQueryCriteria); + } + + return filter; + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTableInsert(CommandLine commandLine, String tableName, ParseResult subParseResult) throws QException + { + InsertInput insertInput = new InsertInput(qInstance); + insertInput.setSession(session); + insertInput.setTableName(tableName); + QTableMetaData table = qInstance.getTable(tableName); + + AbstractQFieldMapping mapping = null; + + if(subParseResult.hasMatchedOption("--mapping")) + { + String json = subParseResult.matchedOptionValue("--mapping", ""); + mapping = new JsonToQFieldMappingAdapter().buildMappingFromJson(json); + } + else + { + mapping = new QKeyBasedFieldMapping(); + for(Map.Entry entry : table.getFields().entrySet()) + { + ((QKeyBasedFieldMapping) mapping).addMapping(entry.getKey(), entry.getValue().getLabel()); + } + } + + ///////////////////////////////////////////// + // get the records that the user specified // + ///////////////////////////////////////////// + List recordList; + if(subParseResult.hasMatchedOption("--jsonBody")) + { + String json = subParseResult.matchedOptionValue("--jsonBody", ""); + recordList = new JsonToQRecordAdapter().buildRecordsFromJson(json, table, mapping); + } + else if(subParseResult.hasMatchedOption("--jsonFile")) + { + try + { + String path = subParseResult.matchedOptionValue("--jsonFile", ""); + String json = FileUtils.readFileToString(new File(path)); + recordList = new JsonToQRecordAdapter().buildRecordsFromJson(json, table, mapping); + } + catch(IOException e) + { + throw (new QException("Error building records from file:" + e.getMessage(), e)); + } + } + else if(subParseResult.hasMatchedOption("--csvFile")) + { + try + { + String path = subParseResult.matchedOptionValue("--csvFile", ""); + String csv = FileUtils.readFileToString(new File(path)); + recordList = new CsvToQRecordAdapter().buildRecordsFromCsv(csv, table, mapping); + } + catch(IOException e) + { + throw (new QException("Error building records from file:" + e.getMessage(), e)); + } + } + else + { + QRecord record = new QRecord(); + recordList = new ArrayList<>(); + recordList.add(record); + + boolean anyFields = false; + for(OptionSpec matchedOption : subParseResult.matchedOptions()) + { + if(matchedOption.longestName().startsWith("--field-")) + { + anyFields = true; + String fieldName = matchedOption.longestName().substring(8); + record.setValue(fieldName, matchedOption.getValue()); + } + } + + if(!anyFields) + { + CommandLine subCommandLine = commandLine.getSubcommands().get("insert"); + subCommandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + } + + insertInput.setRecords(recordList); + + InsertAction insertAction = new InsertAction(); + InsertOutput insertOutput = insertAction.execute(insertInput); + commandLine.getOut().println(JsonUtils.toPrettyJson(insertOutput)); + return commandLine.getCommandSpec().exitCodeOnSuccess(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTableUpdate(CommandLine commandLine, String tableName, ParseResult subParseResult) throws QException + { + UpdateInput updateInput = new UpdateInput(qInstance); + updateInput.setSession(session); + updateInput.setTableName(tableName); + QTableMetaData table = qInstance.getTable(tableName); + + List recordsToUpdate = new ArrayList<>(); + boolean anyFields = false; + + String primaryKeyOption = subParseResult.matchedOptionValue("--primaryKey", ""); + String[] criteria = subParseResult.matchedOptionValue("criteria", new String[] {}); + + if(StringUtils.hasContent(primaryKeyOption)) + { + ////////////////////////////////////////////////////////////////////////////////////// + // if the primaryKey option was given, split it up and seed the recordToUpdate list // + ////////////////////////////////////////////////////////////////////////////////////// + Serializable[] primaryKeyValues = primaryKeyOption.split(","); + for(Serializable primaryKeyValue : primaryKeyValues) + { + recordsToUpdate.add(new QRecord().withValue(table.getPrimaryKeyField(), primaryKeyValue)); + } + } + else if(criteria.length > 0) + { + ////////////////////////////////////////////////////////////////////////////////////// + // else if criteria were given, execute the query for the lsit of records to update // + ////////////////////////////////////////////////////////////////////////////////////// + for(QRecord qRecord : executeQuery(tableName, subParseResult)) + { + recordsToUpdate.add(new QRecord().withValue(table.getPrimaryKeyField(), qRecord.getValue(table.getPrimaryKeyField()))); + } + } + else + { + commandLine.getErr().println("Error: Either primaryKey or criteria must be specified."); + CommandLine subCommandLine = commandLine.getSubcommands().get("update"); + subCommandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + + /////////////////////////////////////////////////// + // make sure at least one --field- arg was given // + /////////////////////////////////////////////////// + for(OptionSpec matchedOption : subParseResult.matchedOptions()) + { + if(matchedOption.longestName().startsWith("--field-")) + { + anyFields = true; + } + } + + if(!anyFields) + { + commandLine.getErr().println("Error: At least one field to update must be specified."); + CommandLine subCommandLine = commandLine.getSubcommands().get("update"); + subCommandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + + if(recordsToUpdate.isEmpty()) + { + commandLine.getErr().println("No rows to update were found."); + CommandLine subCommandLine = commandLine.getSubcommands().get("update"); + subCommandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + + for(QRecord record : recordsToUpdate) + { + for(OptionSpec matchedOption : subParseResult.matchedOptions()) + { + if(matchedOption.longestName().startsWith("--field-")) + { + String fieldName = matchedOption.longestName().substring(8); + record.setValue(fieldName, matchedOption.getValue()); + } + } + } + + updateInput.setRecords(recordsToUpdate); + + UpdateAction updateAction = new UpdateAction(); + UpdateOutput updateResult = updateAction.execute(updateInput); + commandLine.getOut().println(JsonUtils.toPrettyJson(updateResult)); + return commandLine.getCommandSpec().exitCodeOnSuccess(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTableDelete(CommandLine commandLine, String tableName, ParseResult subParseResult) throws QException + { + DeleteInput deleteInput = new DeleteInput(qInstance); + deleteInput.setSession(session); + deleteInput.setTableName(tableName); + + ///////////////////////////////////////////// + // get the pKeys that the user specified // + ///////////////////////////////////////////// + String primaryKeyOption = subParseResult.matchedOptionValue("--primaryKey", ""); + String[] criteria = subParseResult.matchedOptionValue("criteria", new String[] {}); + + if(StringUtils.hasContent(primaryKeyOption)) + { + deleteInput.setPrimaryKeys(Arrays.asList(primaryKeyOption.split(","))); + } + else if(criteria.length > 0) + { + deleteInput.setQueryFilter(generateQueryFilter(subParseResult)); + } + else + { + commandLine.getErr().println("Error: Either primaryKey or criteria must be specified."); + CommandLine subCommandLine = commandLine.getSubcommands().get("delete"); + subCommandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + + DeleteAction deleteAction = new DeleteAction(); + DeleteOutput deleteResult = deleteAction.execute(deleteInput); + commandLine.getOut().println(JsonUtils.toPrettyJson(deleteResult)); + return commandLine.getCommandSpec().exitCodeOnSuccess(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private int runTopLevelCommand(CommandLine commandLine, ParseResult parseResult) throws QException + { + if(parseResult.hasMatchedOption("--meta-data")) + { + MetaDataInput metaDataInput = new MetaDataInput(qInstance); + metaDataInput.setSession(session); + MetaDataAction metaDataAction = new MetaDataAction(); + MetaDataOutput metaDataOutput = metaDataAction.execute(metaDataInput); + commandLine.getOut().println(JsonUtils.toPrettyJson(metaDataOutput)); + return commandLine.getCommandSpec().exitCodeOnSuccess(); + } + + commandLine.usage(commandLine.getOut()); + return commandLine.getCommandSpec().exitCodeOnUsageHelp(); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private List executeQuery(String tableName, ParseResult subParseResult) throws QException + { + QueryInput queryInput = new QueryInput(qInstance); + queryInput.setSession(session); + queryInput.setTableName(tableName); + queryInput.setFilter(generateQueryFilter(subParseResult)); + + QueryAction queryAction = new QueryAction(); + QueryOutput queryOutput = queryAction.execute(queryInput); + return (queryOutput.getRecords()); + } +} diff --git a/src/main/resources/qqq-picocli-log4j2.xml b/src/main/resources/qqq-picocli-log4j2.xml new file mode 100644 index 00000000..5b03a388 --- /dev/null +++ b/src/main/resources/qqq-picocli-log4j2.xml @@ -0,0 +1,18 @@ + + + + %date{ISO8601} | %relative | %level | %threadName{1} | %logger{1}.%method | %message%n + + + + + + + + + + + + + + diff --git a/src/test/java/com/kingsrook/qqq/frontend/picocli/QPicoCliImplementationTest.java b/src/test/java/com/kingsrook/qqq/frontend/picocli/QPicoCliImplementationTest.java new file mode 100644 index 00000000..12d30a86 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/picocli/QPicoCliImplementationTest.java @@ -0,0 +1,966 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.picocli; + + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import org.apache.commons.io.FileUtils; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + + +/******************************************************************************* + ** Unit test for the QPicoCliImplementation. + ** + *******************************************************************************/ +class QPicoCliImplementationTest +{ + private static final boolean VERBOSE = true; + private static final String CLI_NAME = "cli-unit-test"; + + + + /******************************************************************************* + ** Fully rebuild the test-database before each test runs, for completely known state. + ** + *******************************************************************************/ + @BeforeEach + public void beforeEach() throws Exception + { + TestUtils.primeTestDatabase(); + } + + + + /******************************************************************************* + ** test that w/ no arguments you just get usage. + ** + *******************************************************************************/ + @Test + public void test_noArgs() + { + TestOutput testOutput = testCli(); + assertTestOutputContains(testOutput, "Usage: " + CLI_NAME); + } + + + + /******************************************************************************* + ** test that --help gives you usage. + ** + *******************************************************************************/ + @Test + public void test_help() + { + TestOutput testOutput = testCli("--help"); + assertTestOutputContains(testOutput, "Usage: " + CLI_NAME); + assertTestOutputContains(testOutput, "Commands:.*person"); + } + + + + /******************************************************************************* + ** test the --verion argument + ** + *******************************************************************************/ + @Test + public void test_version() + { + TestOutput testOutput = testCli("--version"); + assertTestOutputContains(testOutput, CLI_NAME + " v1.0"); + } + + + + /******************************************************************************* + ** Test that an unrecognized opttion gives an error + ** + *******************************************************************************/ + @Test + public void test_badOption() + { + String badOption = "--asdf"; + TestOutput testOutput = testCli(badOption); + assertTestErrorContains(testOutput, "Unknown option: '" + badOption + "'"); + assertTestErrorContains(testOutput, "Usage: " + CLI_NAME); + } + + + + /******************************************************************************* + ** test the top-level --meta-data option + ** + *******************************************************************************/ + @Test + public void test_metaData() + { + TestOutput testOutput = testCli("--meta-data"); + JSONObject metaData = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(metaData); + assertEquals(2, metaData.keySet().size(), "Number of top-level keys"); + + assertTrue(metaData.has("tables")); + JSONObject tables = metaData.getJSONObject("tables"); + JSONObject personTable = tables.getJSONObject("person"); + assertEquals("person", personTable.getString("name")); + assertEquals("Person", personTable.getString("label")); + + assertTrue(metaData.has("processes")); + JSONObject processes = metaData.getJSONObject("processes"); + JSONObject greetProcess = processes.getJSONObject("greet"); + assertEquals("greet", greetProcess.getString("name")); + assertEquals("Greet", greetProcess.getString("label")); + assertEquals("person", greetProcess.getString("tableName")); + } + + + + /******************************************************************************* + ** test giving a table-name, gives usage for that table + ** + *******************************************************************************/ + @Test + public void test_table() + { + TestOutput testOutput = testCli("person"); + assertTestOutputContains(testOutput, "Usage: " + CLI_NAME + " person \\[COMMAND\\]"); + assertTestOutputContains(testOutput, "Commands:.*query.*process"); + + /////////////////////////////////////////////////////// + // make sure that if there are no processes for the // + // table, that the processes sub-command isn't given // + /////////////////////////////////////////////////////// + QInstance qInstanceWithoutProcesses = TestUtils.defineInstance(); + qInstanceWithoutProcesses.setProcesses(new HashMap<>()); + testOutput = testCli(qInstanceWithoutProcesses, "person"); + assertTestOutputDoesNotContain(testOutput, "process"); + } + + + + /******************************************************************************* + ** test unknown command under table, prints error and usage. + ** + *******************************************************************************/ + @Test + public void test_tableUnknownCommand() + { + String badCommand = "qwuijibo"; + TestOutput testOutput = testCli("person", badCommand); + assertTestErrorContains(testOutput, "Unmatched argument at index 1: '" + badCommand + "'"); + assertTestErrorContains(testOutput, "Usage: " + CLI_NAME + " person \\[COMMAND\\]"); + } + + + + /******************************************************************************* + ** test requesting table meta-data + ** + *******************************************************************************/ + @Test + public void test_tableMetaData() + { + TestOutput testOutput = testCli("person", "meta-data"); + JSONObject metaData = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(metaData); + assertEquals(1, metaData.keySet().size(), "Number of top-level keys"); + JSONObject table = metaData.getJSONObject("table"); + assertEquals("person", table.getString("name")); + assertEquals("Person", table.getString("label")); + assertEquals("id", table.getString("primaryKeyField")); + JSONObject fields = table.getJSONObject("fields"); + JSONObject field0 = fields.getJSONObject("id"); + assertEquals("id", field0.getString("name")); + assertEquals("INTEGER", field0.getString("type")); + } + + + + /******************************************************************************* + ** test running a count on a table + ** + *******************************************************************************/ + @Test + public void test_tableCount() + { + TestOutput testOutput = testCli("person", "count", "--criteria", "id NOT_EQUALS 3"); + JSONObject countResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(countResult); + int count = countResult.getInt("count"); + assertEquals(4, count); + + testOutput = testCli("person", "count", "--criteria", "id EQUALS 3"); + countResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(countResult); + count = countResult.getInt("count"); + assertEquals(1, count); + } + + + + /******************************************************************************* + ** test running a query on a table + ** + *******************************************************************************/ + @Test + public void test_tableQuery() + { + TestOutput testOutput = testCli("person", "query", "--skip=1", "--limit=2", "--criteria", "id NOT_EQUALS 3"); + JSONObject queryResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(queryResult); + JSONArray records = queryResult.getJSONArray("records"); + assertEquals(2, records.length()); + // query for id != 3, and skipping 1, expect to get back rows 2 & 4 + assertEquals(2, records.getJSONObject(0).getJSONObject("values").getInt("id")); + assertEquals(4, records.getJSONObject(1).getJSONObject("values").getInt("id")); + } + + + + /******************************************************************************* + ** test running a "get single record" action (singleton query) on a table + ** + *******************************************************************************/ + @Test + public void test_tableGetNoIdGiven() + { + TestOutput testOutput = testCli("person", "get"); + assertTestOutputContains(testOutput, "Usage: " + CLI_NAME + " person get PARAM"); + assertTestOutputContains(testOutput, "Primary key value from the table"); + } + + + + /******************************************************************************* + ** test running a "get single record" action (singleton query) on a table + ** + *******************************************************************************/ + @Test + public void test_tableGet() + { + TestOutput testOutput = testCli("person", "get", "1"); + JSONObject getResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(getResult); + assertEquals(1, getResult.getJSONObject("values").getInt("id")); + assertEquals("Darin", getResult.getJSONObject("values").getString("firstName")); + } + + + + /******************************************************************************* + ** test running a "get single record" action (singleton query) on a table + ** + *******************************************************************************/ + @Test + public void test_tableGetMissingId() + { + TestOutput testOutput = testCli("person", "get", "1976"); + assertTestOutputContains(testOutput, "No Person found for Id: 1976"); + } + + + + /******************************************************************************* + ** test running an insert w/o specifying any fields, prints usage + ** + *******************************************************************************/ + @Test + public void test_tableInsertNoFieldsPrintsUsage() + { + TestOutput testOutput = testCli("person", "insert"); + assertTestOutputContains(testOutput, "Usage: " + CLI_NAME + " person insert"); + } + + + + /******************************************************************************* + ** test running an insert w/ fields as arguments + ** + *******************************************************************************/ + @Test + public void test_tableInsertFieldArguments() + { + TestOutput testOutput = testCli("person", "insert", + "--field-firstName=Lucy", + "--field-lastName=Lu", + "--field-email=lucy@kingsrook.com"); + JSONObject insertResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(insertResult); + assertEquals(1, insertResult.getJSONArray("records").length()); + assertEquals(6, insertResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getInt("id")); + } + + + + /******************************************************************************* + ** test running an insert w/ a mapping and json as an argument + ** + *******************************************************************************/ + @Test + public void test_tableInsertJsonObjectArgumentWithMapping() + { + String mapping = """ + --mapping={"firstName":"first","lastName":"ln","email":"email"} + """; + + String jsonBody = """ + --jsonBody={"first":"Chester","ln":"Cheese","email":"chester@kingsrook.com"} + """; + + TestOutput testOutput = testCli("person", "insert", mapping, jsonBody); + JSONObject insertResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(insertResult); + assertEquals(1, insertResult.getJSONArray("records").length()); + assertEquals(6, insertResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getInt("id")); + assertEquals("Chester", insertResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getString("firstName")); + assertEquals("Cheese", insertResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getString("lastName")); + assertEquals("chester@kingsrook.com", insertResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getString("email")); + } + + + + /******************************************************************************* + ** test running an insert w/ a mapping and json as a multi-record file + ** + *******************************************************************************/ + @Test + public void test_tableInsertJsonArrayFileWithMapping() throws IOException + { + String mapping = """ + --mapping={"firstName":"first","lastName":"ln","email":"email"} + """; + + String jsonContents = """ + [ + {"first":"Charlie","ln":"Bear","email":"charlie-bear@kingsrook.com"}, + {"first":"Coco","ln":"Bean","email":"coco-bean@kingsrook.com"} + ] + """; + + File file = new File("/tmp/" + UUID.randomUUID() + ".json"); + file.deleteOnExit(); + FileUtils.writeStringToFile(file, jsonContents); + + TestOutput testOutput = testCli("person", "insert", mapping, "--jsonFile=" + file.getAbsolutePath()); + JSONObject insertResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(insertResult); + JSONArray records = insertResult.getJSONArray("records"); + assertEquals(2, records.length()); + assertEquals(6, insertResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getInt("id")); + assertEquals(7, insertResult.getJSONArray("records").getJSONObject(1).getJSONObject("values").getInt("id")); + assertEquals("Charlie", records.getJSONObject(0).getJSONObject("values").getString("firstName")); + assertEquals("Bear", records.getJSONObject(0).getJSONObject("values").getString("lastName")); + assertEquals("charlie-bear@kingsrook.com", records.getJSONObject(0).getJSONObject("values").getString("email")); + assertEquals("Coco", records.getJSONObject(1).getJSONObject("values").getString("firstName")); + assertEquals("Bean", records.getJSONObject(1).getJSONObject("values").getString("lastName")); + assertEquals("coco-bean@kingsrook.com", records.getJSONObject(1).getJSONObject("values").getString("email")); + } + + + + /******************************************************************************* + ** test running an insert w/ an index-based mapping and csv file + ** + *******************************************************************************/ + @Test + public void test_tableInsertCsvFileWithIndexMapping() throws IOException + { + String mapping = """ + --mapping={"firstName":1,"lastName":3,"email":5} + """; + + String csvContents = """ + "Louis","P","Willikers",1024,"louis@kingsrook.com", + "Nestle","G","Crunch",1701,"nestle@kingsrook.com", + + """; + + File file = new File("/tmp/" + UUID.randomUUID() + ".csv"); + file.deleteOnExit(); + FileUtils.writeStringToFile(file, csvContents); + + TestOutput testOutput = testCli("person", "insert", mapping, "--csvFile=" + file.getAbsolutePath()); + JSONObject insertResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(insertResult); + JSONArray records = insertResult.getJSONArray("records"); + assertEquals(2, records.length()); + assertEquals(6, insertResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getInt("id")); + assertEquals(7, insertResult.getJSONArray("records").getJSONObject(1).getJSONObject("values").getInt("id")); + assertEquals("Louis", records.getJSONObject(0).getJSONObject("values").getString("firstName")); + assertEquals("Willikers", records.getJSONObject(0).getJSONObject("values").getString("lastName")); + assertEquals("Nestle", records.getJSONObject(1).getJSONObject("values").getString("firstName")); + assertEquals("Crunch", records.getJSONObject(1).getJSONObject("values").getString("lastName")); + } + + + + /******************************************************************************* + ** test running an update w/o specifying any fields, prints usage + ** + *******************************************************************************/ + @Test + public void test_tableUpdateNoFieldsPrintsUsage() + { + TestOutput testOutput = testCli("person", "update"); + assertTestOutputContains(testOutput, "Usage: " + CLI_NAME + " person update"); + } + + + + /******************************************************************************* + ** test running an update w/o specifying any pkeys or criteria, prints usage + ** + *******************************************************************************/ + @Test + public void test_tableUpdateNoRecordsPrintsUsage() + { + TestOutput testOutput = testCli("person", "update", "--field-firstName=Lucy"); + assertTestOutputContains(testOutput, "Usage: " + CLI_NAME + " person update"); + } + + + + /******************************************************************************* + ** test running an update w/ fields as arguments and one primary key + ** + *******************************************************************************/ + @Test + public void test_tableUpdateFieldArgumentsOnePrimaryKey() throws Exception + { + assertRowValueById("person", "first_name", "Garret", 5); + TestOutput testOutput = testCli("person", "update", + "--primaryKey=5", + "--field-firstName=Lucy", + "--field-lastName=Lu"); + JSONObject updateResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(updateResult); + assertEquals(1, updateResult.getJSONArray("records").length()); + assertEquals(5, updateResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getInt("id")); + assertRowValueById("person", "first_name", "Lucy", 5); + } + + + + /******************************************************************************* + ** test running an update w/ fields as arguments and multiple primary keys + ** + *******************************************************************************/ + @Test + public void test_tableUpdateFieldArgumentsManyPrimaryKeys() throws Exception + { + assertRowValueById("person", "first_name", "Tyler", 4); + assertRowValueById("person", "first_name", "Garret", 5); + TestOutput testOutput = testCli("person", "update", + "--primaryKey=4,5", + "--field-birthDate=1980-05-31", + "--field-firstName=Lucy", + "--field-lastName=Lu"); + JSONObject updateResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(updateResult); + assertEquals(2, updateResult.getJSONArray("records").length()); + assertEquals(4, updateResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getInt("id")); + assertEquals(5, updateResult.getJSONArray("records").getJSONObject(1).getJSONObject("values").getInt("id")); + assertRowValueById("person", "first_name", "Lucy", 4); + assertRowValueById("person", "first_name", "Lucy", 5); + } + + + + /******************************************************************************* + ** test running an update w/ fields as arguments and a criteria + ** + *******************************************************************************/ + @Test + public void test_tableUpdateFieldArgumentsCriteria() throws Exception + { + assertRowValueById("person", "first_name", "Tyler", 4); + assertRowValueById("person", "first_name", "Garret", 5); + TestOutput testOutput = testCli("person", "update", + "--criteria", + "id GREATER_THAN_OR_EQUALS 4", + "--field-firstName=Lucy", + "--field-lastName=Lu"); + JSONObject updateResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(updateResult); + assertEquals(2, updateResult.getJSONArray("records").length()); + assertEquals(4, updateResult.getJSONArray("records").getJSONObject(0).getJSONObject("values").getInt("id")); + assertEquals(5, updateResult.getJSONArray("records").getJSONObject(1).getJSONObject("values").getInt("id")); + assertRowValueById("person", "first_name", "Lucy", 4); + assertRowValueById("person", "first_name", "Lucy", 5); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void assertRowValueById(String tableName, String columnName, String value, Integer id) throws Exception + { + TestUtils.runTestSql("SELECT " + columnName + " FROM " + tableName + " WHERE id=" + id, (rs -> { + if(rs.next()) + { + assertEquals(value, rs.getString(1)); + } + else + { + fail("Row not found"); + } + })); + } + + + + /******************************************************************************* + ** test running a delete without enough args + ** + *******************************************************************************/ + @Test + public void test_tableDeleteWithoutArgs() throws Exception + { + TestOutput testOutput = testCli("person", "delete"); + assertTestOutputContains(testOutput, "Usage: " + CLI_NAME + " person delete"); + } + + + /******************************************************************************* + ** test running a delete against a table + ** + *******************************************************************************/ + @Test + public void test_tableDelete() throws Exception + { + TestOutput testOutput = testCli("person", "delete", "--primaryKey", "2,4"); + JSONObject deleteResult = JsonUtils.toJSONObject(testOutput.getOutput()); + assertNotNull(deleteResult); + assertEquals(2, deleteResult.getInt("deletedRecordCount")); + TestUtils.runTestSql("SELECT id FROM person", (rs -> { + int rowsFound = 0; + while(rs.next()) + { + rowsFound++; + assertTrue(rs.getInt(1) == 1 || rs.getInt(1) == 3 || rs.getInt(1) == 5); + } + assertEquals(3, rowsFound); + })); + } + + + + /******************************************************************************* + ** test requesting the list of processes for a table + ** + *******************************************************************************/ + @Test + public void test_tableProcess() + { + TestOutput testOutput = testCli("person", "process"); + + //////////////////////////////////////////////// + // should list the processes under this table // + //////////////////////////////////////////////// + assertTestOutputContains(testOutput, "Commands.*greet"); + } + + + + /******************************************************************************* + ** test trying to run a process, but giving an invalid name. + ** + *******************************************************************************/ + @Test + public void test_tableProcessUnknownName() + { + String badProcessName = "not-a-process"; + TestOutput testOutput = testCli("person", "process", badProcessName); + assertTestErrorContains(testOutput, "Unmatched argument at index 2: '" + badProcessName + "'"); + assertTestErrorContains(testOutput, "Usage: " + CLI_NAME + " person process \\[COMMAND\\]"); + } + + + + /******************************************************************************* + ** test running a process on a table + ** + *******************************************************************************/ + @Test + public void test_tableProcessGreetUsingCallbackForFields() + { + setStandardInputLines("Hi", "How are you?"); + TestOutput testOutput = testCli("person", "process", "greet"); + assertTestOutputContains(testOutput, "Please supply a value for the field.*Greeting Prefix"); + assertTestOutputContains(testOutput, "Hi X How are you?"); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportNoArgsExcel() + { + String filename = "/tmp/" + UUID.randomUUID() + ".xlsx"; + TestOutput testOutput = testCli("person", "export", "--filename=" + filename); + assertTestOutputContains(testOutput, "Wrote 5 records to file " + filename); + + File file = new File(filename); + assertTrue(file.exists()); + + // todo - some day when we learn to read Excel, assert that we wrote as expected. + + deleteFile(file); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportWithLimit() throws Exception + { + String filename = "/tmp/" + UUID.randomUUID() + ".csv"; + TestOutput testOutput = testCli("person", "export", "--filename=" + filename, "--limit=3"); + assertTestOutputContains(testOutput, "Wrote 3 records to file " + filename); + + File file = new File(filename); + @SuppressWarnings("unchecked") + List list = FileUtils.readLines(file); + assertEquals(4, list.size()); + assertThat(list.get(0)).contains(""" + "Id","Create Date","Modify Date\""""); + assertThat(list.get(1)).matches(""" + ^"1",.*"Darin.*"""); + assertThat(list.get(3)).matches(""" + ^"3",.*"Tim.*"""); + + deleteFile(file); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportWithCriteria() throws Exception + { + String filename = "/tmp/" + UUID.randomUUID() + ".csv"; + TestOutput testOutput = testCli("person", "export", "--filename=" + filename, "--criteria", "id NOT_EQUALS 3"); + assertTestOutputContains(testOutput, "Wrote 4 records to file " + filename); + + File file = new File(filename); + @SuppressWarnings("unchecked") + List list = FileUtils.readLines(file); + assertEquals(5, list.size()); + assertThat(list.get(0)).contains(""" + "Id","Create Date","Modify Date\""""); + assertThat(list.get(1)).matches("^\"1\",.*"); + assertThat(list.get(2)).matches("^\"2\",.*"); + assertThat(list.get(3)).matches("^\"4\",.*"); + assertThat(list.get(4)).matches("^\"5\",.*"); + + deleteFile(file); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportWithoutFilename() + { + TestOutput testOutput = testCli("person", "export"); + assertTestErrorContains(testOutput, "Missing required option: '--filename=PARAM'"); + assertTestErrorContains(testOutput, "Usage: " + CLI_NAME + " person export"); + assertTestErrorContains(testOutput, "-f=PARAM"); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportNoFileExtension() + { + String filename = "/tmp/" + UUID.randomUUID(); + TestOutput testOutput = testCli("person", "export", "--filename=" + filename); + assertTestErrorContains(testOutput, "File name did not contain an extension"); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportBadFileType() + { + String filename = "/tmp/" + UUID.randomUUID() + ".docx"; + TestOutput testOutput = testCli("person", "export", "--filename=" + filename); + assertTestErrorContains(testOutput, "Unsupported report format: docx."); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportBadFilePath() + { + String filename = "/no-such/directory/" + UUID.randomUUID() + "report.csv"; + TestOutput testOutput = testCli("person", "export", "--filename=" + filename); + assertTestErrorContains(testOutput, "No such file or directory"); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportBadFieldNams() + { + String filename = "/tmp/" + UUID.randomUUID() + ".csv"; + TestOutput testOutput = testCli("person", "export", "--filename=" + filename, "--fieldNames=foo"); + assertTestErrorContains(testOutput, "Field name foo was not found on the Person table"); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportBadFieldNames() + { + String filename = "/tmp/" + UUID.randomUUID() + ".csv"; + TestOutput testOutput = testCli("person", "export", "--filename=" + filename, "--fieldNames=foo,bar,baz"); + assertTestErrorContains(testOutput, "Fields names foo, bar, and baz were not found on the Person table"); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportGoodFieldNamesXslx() throws IOException + { + String filename = "/tmp/" + UUID.randomUUID() + ".xlsx"; + TestOutput testOutput = testCli("person", "export", "--filename=" + filename, "--fieldNames=id,lastName,birthDate"); + + File file = new File(filename); + assertTrue(file.exists()); + + // todo - some day when we learn to read Excel, assert that we wrote as expected (with 3 columns) + + deleteFile(file); + } + + + + /******************************************************************************* + ** test exporting a table + ** + *******************************************************************************/ + @Test + public void test_tableExportGoodFieldNamesCSV() throws IOException + { + String filename = "/tmp/" + UUID.randomUUID() + ".csv"; + TestOutput testOutput = testCli("person", "export", "--filename=" + filename, "--fieldNames=id,lastName,birthDate"); + + File file = new File(filename); + @SuppressWarnings("unchecked") + List list = FileUtils.readLines(file); + assertEquals(6, list.size()); + assertThat(list.get(0)).isEqualTo(""" + "Id","Last Name","Birth Date\""""); + assertThat(list.get(1)).isEqualTo(""" + "1","Kelkhoff","1980-05-31\""""); + + deleteFile(file); + } + + + + /******************************************************************************* + ** test running a process on a table + ** + *******************************************************************************/ + @Test + public void test_tableProcessGreetUsingOptionsForFields() + { + TestOutput testOutput = testCli("person", "process", "greet", "--field-greetingPrefix=Hello", "--field-greetingSuffix=World"); + assertTestOutputDoesNotContain(testOutput, "Please supply a value for the field"); + assertTestOutputContains(testOutput, "Hello X World"); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void assertTestOutputContains(TestOutput testOutput, String expectedRegexSubstring) + { + if(!testOutput.getOutput().matches("(?s).*" + expectedRegexSubstring + ".*")) + { + fail("Expected output to contain this regex pattern:\n" + expectedRegexSubstring + + "\nBut it did not. The full output was:\n" + testOutput.getOutput()); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void assertTestOutputDoesNotContain(TestOutput testOutput, String expectedRegexSubstring) + { + if(testOutput.getOutput().matches("(?s).*" + expectedRegexSubstring + ".*")) + { + fail("Expected output to not contain this regex pattern:\n" + expectedRegexSubstring + + "\nBut it did. The full output was:\n" + testOutput.getOutput()); + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void assertTestErrorContains(TestOutput testOutput, String expectedRegexSubstring) + { + if(!testOutput.getError().matches("(?s).*" + expectedRegexSubstring + ".*")) + { + fail("Expected error-output to contain this regex pattern:\n" + expectedRegexSubstring + + "\nBut it did not. The full error-output was:\n" + testOutput.getOutput()); + } + } + + + + /******************************************************************************* + ** delete a file, asserting that we did so. + *******************************************************************************/ + private void deleteFile(File file) + { + assertTrue(file.delete()); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private TestOutput testCli(String... args) + { + QInstance qInstance = TestUtils.defineInstance(); + return testCli(qInstance, args); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private TestOutput testCli(QInstance qInstance, String... args) + { + QPicoCliImplementation qPicoCliImplementation = new QPicoCliImplementation(qInstance); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ByteArrayOutputStream errorStream = new ByteArrayOutputStream(); + + if(VERBOSE) + { + System.out.println("> " + CLI_NAME + (args == null ? "" : " " + StringUtils.join(" ", Arrays.stream(args).toList()))); + } + + qPicoCliImplementation.runCli(CLI_NAME, args, new PrintStream(outputStream, true), new PrintStream(errorStream, true)); + + String output = outputStream.toString(StandardCharsets.UTF_8); + String error = errorStream.toString(StandardCharsets.UTF_8); + + if(VERBOSE) + { + System.out.println(output); + System.err.println(error); + } + + TestOutput testOutput = new TestOutput(output, error); + return (testOutput); + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + private void setStandardInputLines(String... lines) + { + StringBuilder stringBuilder = new StringBuilder(); + for(String line : lines) + { + stringBuilder.append(line); + if(!line.endsWith("\n")) + { + stringBuilder.append("\n"); + } + } + ByteArrayInputStream stdin = new ByteArrayInputStream(stringBuilder.toString().getBytes(Charset.defaultCharset())); + System.setIn(stdin); + } + +} diff --git a/src/test/java/com/kingsrook/qqq/frontend/picocli/TestOutput.java b/src/test/java/com/kingsrook/qqq/frontend/picocli/TestOutput.java new file mode 100644 index 00000000..fcbb76d8 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/picocli/TestOutput.java @@ -0,0 +1,136 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.picocli; + + +/******************************************************************************* + ** + *******************************************************************************/ +class TestOutput +{ + private String output; + private String[] outputLines; + private String error; + private String[] errorLines; + + + + /******************************************************************************* + ** + *******************************************************************************/ + public TestOutput(String output, String error) + { + this.output = output; + this.error = error; + + this.outputLines = output.split("\n"); + this.errorLines = error.split("\n"); + } + + + + /******************************************************************************* + ** Getter for output + ** + *******************************************************************************/ + public String getOutput() + { + return output; + } + + + + /******************************************************************************* + ** Setter for output + ** + *******************************************************************************/ + public void setOutput(String output) + { + this.output = output; + } + + + + /******************************************************************************* + ** Getter for outputLines + ** + *******************************************************************************/ + public String[] getOutputLines() + { + return outputLines; + } + + + + /******************************************************************************* + ** Setter for outputLines + ** + *******************************************************************************/ + public void setOutputLines(String[] outputLines) + { + this.outputLines = outputLines; + } + + + + /******************************************************************************* + ** Getter for error + ** + *******************************************************************************/ + public String getError() + { + return error; + } + + + + /******************************************************************************* + ** Setter for error + ** + *******************************************************************************/ + public void setError(String error) + { + this.error = error; + } + + + + /******************************************************************************* + ** Getter for errorLines + ** + *******************************************************************************/ + public String[] getErrorLines() + { + return errorLines; + } + + + + /******************************************************************************* + ** Setter for errorLines + ** + *******************************************************************************/ + public void setErrorLines(String[] errorLines) + { + this.errorLines = errorLines; + } +} diff --git a/src/test/java/com/kingsrook/qqq/frontend/picocli/TestUtils.java b/src/test/java/com/kingsrook/qqq/frontend/picocli/TestUtils.java new file mode 100644 index 00000000..434b9d39 --- /dev/null +++ b/src/test/java/com/kingsrook/qqq/frontend/picocli/TestUtils.java @@ -0,0 +1,189 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2022. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.kingsrook.qqq.frontend.picocli; + + +import java.io.InputStream; +import java.sql.Connection; +import java.util.List; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.processes.implementations.mock.MockBackendStep; +import com.kingsrook.qqq.backend.core.modules.authentication.metadata.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeType; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeUsage; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionOutputMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager; +import com.kingsrook.qqq.backend.module.rdbms.jdbc.QueryManager; +import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData; +import org.apache.commons.io.IOUtils; +import static junit.framework.Assert.assertNotNull; + + +/******************************************************************************* + ** Utility methods for unit tests. + ** + *******************************************************************************/ +public class TestUtils +{ + + /******************************************************************************* + ** Prime a test database (e.g., h2, in-memory) + ** + *******************************************************************************/ + @SuppressWarnings("unchecked") + public static void primeTestDatabase() throws Exception + { + ConnectionManager connectionManager = new ConnectionManager(); + Connection connection = connectionManager.getConnection(TestUtils.defineBackend()); + InputStream primeTestDatabaseSqlStream = TestUtils.class.getResourceAsStream("/prime-test-database.sql"); + assertNotNull(primeTestDatabaseSqlStream); + List lines = (List) IOUtils.readLines(primeTestDatabaseSqlStream); + lines = lines.stream().filter(line -> !line.startsWith("-- ")).toList(); + String joinedSQL = String.join("\n", lines); + for(String sql : joinedSQL.split(";")) + { + QueryManager.executeUpdate(connection, sql); + } + } + + + + /******************************************************************************* + ** Run an SQL Query in the test database + ** + *******************************************************************************/ + public static void runTestSql(String sql, QueryManager.ResultSetProcessor resultSetProcessor) throws Exception + { + ConnectionManager connectionManager = new ConnectionManager(); + Connection connection = connectionManager.getConnection(defineBackend()); + QueryManager.executeStatement(connection, sql, resultSetProcessor); + } + + + + /******************************************************************************* + ** Define the q-instance for testing (h2 rdbms and 'person' table) + ** + *******************************************************************************/ + public static QInstance defineInstance() + { + QInstance qInstance = new QInstance(); + qInstance.setAuthentication(defineAuthentication()); + qInstance.addBackend(defineBackend()); + qInstance.addTable(defineTablePerson()); + qInstance.addProcess(defineProcessGreetPeople()); + return (qInstance); + } + + + + /******************************************************************************* + ** Define the authentication used in standard tests - using 'mock' type. + ** + *******************************************************************************/ + private static QAuthenticationMetaData defineAuthentication() + { + return new QAuthenticationMetaData() + .withName("mock") + .withType(QAuthenticationType.MOCK); + } + + + + /******************************************************************************* + ** Define the h2 rdbms backend + ** + *******************************************************************************/ + public static RDBMSBackendMetaData defineBackend() + { + return (new RDBMSBackendMetaData() + .withVendor("h2") + .withHostName("mem") + .withDatabaseName("test_database") + .withUsername("sa") + .withPassword("") + .withName("default")); + } + + + + /******************************************************************************* + ** Define the person table + ** + *******************************************************************************/ + public static QTableMetaData defineTablePerson() + { + return new QTableMetaData() + .withName("person") + .withLabel("Person") + .withBackendName(defineBackend().getName()) + .withPrimaryKeyField("id") + .withField(new QFieldMetaData("id", QFieldType.INTEGER)) + .withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withBackendName("create_date")) + .withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withBackendName("modify_date")) + .withField(new QFieldMetaData("firstName", QFieldType.STRING).withBackendName("first_name")) + .withField(new QFieldMetaData("lastName", QFieldType.STRING).withBackendName("last_name")) + .withField(new QFieldMetaData("birthDate", QFieldType.DATE).withBackendName("birth_date")) + .withField(new QFieldMetaData("email", QFieldType.STRING)); + } + + + + /******************************************************************************* + ** Define the 'greet people' process + *******************************************************************************/ + private static QProcessMetaData defineProcessGreetPeople() + { + return new QProcessMetaData() + .withName("greet") + .withTableName("person") + .addStep(new QBackendStepMetaData() + .withName("prepare") + .withCode(new QCodeReference() + .withName(MockBackendStep.class.getName()) + .withCodeType(QCodeType.JAVA) + .withCodeUsage(QCodeUsage.BACKEND_STEP)) // todo - needed, or implied in this context? + .withInputData(new QFunctionInputMetaData() + .withRecordListMetaData(new QRecordListMetaData().withTableName("person")) + .withFieldList(List.of( + new QFieldMetaData("greetingPrefix", QFieldType.STRING), + new QFieldMetaData("greetingSuffix", QFieldType.STRING) + ))) + .withOutputMetaData(new QFunctionOutputMetaData() + .withRecordListMetaData(new QRecordListMetaData() + .withTableName("person") + .addField(new QFieldMetaData("fullGreeting", QFieldType.STRING)) + ) + .withFieldList(List.of(new QFieldMetaData("outputMessage", QFieldType.STRING)))) + ); + } + +} diff --git a/src/test/resources/personQInstance.json b/src/test/resources/personQInstance.json new file mode 100644 index 00000000..250e5f57 --- /dev/null +++ b/src/test/resources/personQInstance.json @@ -0,0 +1,156 @@ +{ + "authentication": { + "name": "mock", + "type": "mock", + "values": null + }, + "tables": { + "person": { + "name": "person", + "label": "Person", + "backendName": "default", + "primaryKeyField": "id", + "fields": { + "id": { + "name": "id", + "label": null, + "backendName": null, + "type": "INTEGER", + "possibleValueSourceName": null + }, + "createDate": { + "name": "createDate", + "label": null, + "backendName": null, + "type": "DATE_TIME", + "possibleValueSourceName": null + }, + "modifyDate": { + "name": "modifyDate", + "label": null, + "backendName": null, + "type": "DATE_TIME", + "possibleValueSourceName": null + }, + "firstName": { + "name": "firstName", + "label": null, + "backendName": null, + "type": "STRING", + "possibleValueSourceName": null + }, + "lastName": { + "name": "lastName", + "label": null, + "backendName": null, + "type": "STRING", + "possibleValueSourceName": null + }, + "birthDate": { + "name": "birthDate", + "label": null, + "backendName": null, + "type": "DATE", + "possibleValueSourceName": null + }, + "email": { + "name": "email", + "label": null, + "backendName": null, + "type": "STRING", + "possibleValueSourceName": null + }, + "homeState": { + "name": "homeState", + "label": null, + "backendName": null, + "type": "STRING", + "possibleValueSourceName": "state" + } + } + } + }, + "possibleValueSources": { + "state": { + "name": "state", + "type": "ENUM", + "enumValues": [ + "IL", + "MO" + ] + } + }, + "processes": { + "greet": { + "name": "greet", + "tableName": "person", + "functionList": [ + { + "name": "prepare", + "label": null, + "inputMetaData": { + "recordListMetaData": { + "tableName": "person", + "fields": null + }, + "fieldList": [ + { + "name": "greetingPrefix", + "label": null, + "backendName": null, + "type": "STRING", + "possibleValueSourceName": null + }, + { + "name": "greetingSuffix", + "label": null, + "backendName": null, + "type": "STRING", + "possibleValueSourceName": null + } + ] + }, + "outputMetaData": { + "recordListMetaData": { + "tableName": "person", + "fields": { + "fullGreeting": { + "name": "fullGreeting", + "label": null, + "backendName": null, + "type": "STRING", + "possibleValueSourceName": null + } + } + }, + "fieldList": [ + { + "name": "outputMessage", + "label": null, + "backendName": null, + "type": "STRING", + "possibleValueSourceName": null + } + ] + }, + "code": { + "name": "com.kingsrook.qqq.backend.core.interfaces.mock.MockFunctionBody", + "codeType": "JAVA", + "codeUsage": "FUNCTION" + }, + "outputView": { + "messageField": "outputMessage", + "recordListView": { + "fieldNames": [ + "id", + "firstName", + "lastName", + "fullGreeting" + ] + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/src/test/resources/personQInstanceIncludingBackend.json b/src/test/resources/personQInstanceIncludingBackend.json new file mode 100644 index 00000000..74095833 --- /dev/null +++ b/src/test/resources/personQInstanceIncludingBackend.json @@ -0,0 +1,163 @@ +{ + "tables": { + "person": { + "primaryKeyField": "id", + "name": "person", + "backendName": "default", + "label": "Person", + "fields": { + "firstName": { + "name": "firstName", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": null + }, + "lastName": { + "name": "lastName", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": null + }, + "modifyDate": { + "name": "modifyDate", + "backendName": null, + "label": null, + "type": "DATE_TIME", + "possibleValueSourceName": null + }, + "homeState": { + "name": "homeState", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": "state" + }, + "id": { + "name": "id", + "backendName": null, + "label": null, + "type": "INTEGER", + "possibleValueSourceName": null + }, + "birthDate": { + "name": "birthDate", + "backendName": null, + "label": null, + "type": "DATE", + "possibleValueSourceName": null + }, + "email": { + "name": "email", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": null + }, + "createDate": { + "name": "createDate", + "backendName": null, + "label": null, + "type": "DATE_TIME", + "possibleValueSourceName": null + } + } + } + }, + "processes": { + "greet": { + "functionList": [ + { + "code": { + "codeUsage": "FUNCTION", + "codeType": "JAVA", + "name": "com.kingsrook.qqq.backend.core.interfaces.mock.MockFunctionBody" + }, + "inputMetaData": { + "recordListMetaData": { + "fields": null, + "tableName": "person" + }, + "fieldList": [ + { + "name": "greetingPrefix", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": null + }, + { + "name": "greetingSuffix", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": null + } + ] + }, + "outputMetaData": { + "recordListMetaData": { + "fields": { + "fullGreeting": { + "name": "fullGreeting", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": null + } + }, + "tableName": "person" + }, + "fieldList": [ + { + "name": "outputMessage", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": null + } + ] + }, + "outputView": { + "messageField": "outputMessage", + "recordListView": { + "fieldNames": [ + "id", + "firstName", + "lastName", + "fullGreeting" + ] + } + }, + "name": "prepare", + "label": null + } + ], + "name": "greet", + "tableName": "person" + } + }, + "possibleValueSources": { + "state": { + "name": "state", + "type": "ENUM", + "enumValues": [ + "IL", + "MO" + ] + } + }, + "backends": { + "default": { + "values": null, + "name": "default", + "type": "mock" + } + }, + "authentication": { + "values": null, + "name": "mock", + "type": "mock" + } +} \ No newline at end of file diff --git a/src/test/resources/prime-test-database.sql b/src/test/resources/prime-test-database.sql new file mode 100644 index 00000000..be858987 --- /dev/null +++ b/src/test/resources/prime-test-database.sql @@ -0,0 +1,39 @@ +-- +-- QQQ - Low-code Application Framework for Engineers. +-- Copyright (C) 2021-2022. Kingsrook, LLC +-- 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States +-- contact@kingsrook.com +-- https://github.com/Kingsrook/ +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as +-- published by the Free Software Foundation, either version 3 of the +-- License, or (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . +-- + +DROP TABLE IF EXISTS person; +CREATE TABLE person +( + id INT AUTO_INCREMENT, + create_date TIMESTAMP DEFAULT now(), + modify_date TIMESTAMP DEFAULT now(), + + first_name VARCHAR(80) NOT NULL, + last_name VARCHAR(80) NOT NULL, + birth_date DATE, + email VARCHAR(250) NOT NULL +); + +INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (1, 'Darin', 'Kelkhoff', '1980-05-31', 'darin.kelkhoff@gmail.com'); +INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (2, 'James', 'Maes', '1980-05-15', 'jmaes@mmltholdings.com'); +INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (3, 'Tim', 'Chamberlain', '1976-05-28', 'tchamberlain@mmltholdings.com'); +INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (4, 'Tyler', 'Samples', '1990-01-01', 'tsamples@mmltholdings.com'); +INSERT INTO person (id, first_name, last_name, birth_date, email) VALUES (5, 'Garret', 'Richardson', '1981-01-01', 'grichardson@mmltholdings.com'); diff --git a/src/test/resources/starTrekDatabaseQInstanceIncludingBackend.json b/src/test/resources/starTrekDatabaseQInstanceIncludingBackend.json new file mode 100644 index 00000000..69d0389a --- /dev/null +++ b/src/test/resources/starTrekDatabaseQInstanceIncludingBackend.json @@ -0,0 +1,70 @@ +{ + "tables": { + "series": { + "primaryKeyField": "id", + "name": "series", + "backendName": "wherenooneMysql", + "label": "Person", + "fields": { + "name": { + "name": "name", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": null + }, + "abbreviation": { + "name": "abbreviation", + "backendName": null, + "label": null, + "type": "STRING", + "possibleValueSourceName": null + }, + "id": { + "name": "id", + "backendName": null, + "label": null, + "type": "INTEGER", + "possibleValueSourceName": null + }, + "createDate": { + "name": "createDate", + "backendName": "create_date", + "label": "Create Date", + "type": "DATE_TIME", + "possibleValueSourceName": null + }, + "modifyDate": { + "name": "modifyDate", + "backendName": "modify_date", + "label": "Modify Date", + "type": "DATE_TIME", + "possibleValueSourceName": null + } + } + } + }, + "processes": { + }, + "possibleValueSources": { + }, + "backends": { + "wherenooneMysql": { + "values": { + "vendor": "mysql", + "hostName": "localhost", + "port": "3306", + "databaseName": "wherenoone", + "username": "root", + "password": "password" + }, + "name": "wherenooneMysql", + "type": "rdbms" + } + }, + "authentication": { + "values": null, + "name": "mock", + "type": "mock" + } +}