diff --git a/README.md b/README.md index 09b01ee600..1763e754f9 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ Please refer to [the contributing.md file](CONTRIBUTING.md) for information abou If you are contributing to Elide using an IDE, such as IntelliJ, make sure to install the [Lombok](https://projectlombok.org/) plugin. -Community chat is now on [discord](https://discord.com/widget?id=869678398241398854&theme=dark). +Community chat is now on [discord](https://discord.com/widget?id=869678398241398854&theme=dark). Join by clicking [here](https://discord.gg/3vh8ac57cc). Legacy discussion is archived on [spectrum](https://spectrum.chat/elide). ## License diff --git a/changelog.md b/changelog.md index 173c1074a6..c612b611ca 100644 --- a/changelog.md +++ b/changelog.md @@ -1,4 +1,525 @@ # Change Log + +## 6.1.9 +**Features** +* [view commit](https://github.com/yahoo/elide/commit/86203f05f61b3beb65a3a6f6ba502727f4d1f4d7) Adding support for populating Meta object in JSON-API (#2824) +* [view commit](https://github.com/yahoo/elide/commit/9ca2a8fef6468c66804ee0dd94d4125b90a52ad2) Add negated prefix, postfix and infix operators. (#2788) (#2830) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/18a76165de0eb14a10005ae49e2c6e862bf33a65) Bump graphql-java-extended-scalars from 18.1 to 19.0 (#2783) + * [view commit](https://github.com/yahoo/elide/commit/362842b23009e806e4395d880f1ec481a3458459) Bump groovy.version from 3.0.12 to 3.0.13 (#2782) + * [view commit](https://github.com/yahoo/elide/commit/c61859e11360fb9063c7187959ade2b2246c11e4) Resolves #2784 (#2785) + * [view commit](https://github.com/yahoo/elide/commit/f03aba8c1f854a9c0c286fad97c30d2fed5ffe08) Allow elide-graphql to be excluded when elide.graphql.enabled=false (#2787) + * [view commit](https://github.com/yahoo/elide/commit/b700526b2e66dd8eac64868dc3060a7b762cdc96) Bump federation-graphql-java-support from 2.0.7 to 2.0.8 (#2793) + * [view commit](https://github.com/yahoo/elide/commit/8df8d99fd3f1c6261f163a73c354eb0b162a6e90) Bump version.junit from 5.9.0 to 5.9.1 (#2792) + * [view commit](https://github.com/yahoo/elide/commit/6212ec358649ebf8b24ec872b80d83fae65ed7a1) Bump spring.boot.version from 2.7.3 to 2.7.4 (#2791) + * [view commit](https://github.com/yahoo/elide/commit/0bcfbb184d15cf71a84434172655b861cc7f8f06) Bump junit-platform-launcher from 1.9.0 to 1.9.1 (#2790) + * [view commit](https://github.com/yahoo/elide/commit/c497ff3e67232c2fa0d74bc8d86905b14836de11) Bump hibernate5.version from 5.6.11.Final to 5.6.12.Final (#2798) + * [view commit](https://github.com/yahoo/elide/commit/f8ec54e864aebf2b7b54bce0af8ba0a474038311) Bump snakeyaml from 1.32 to 1.33 (#2796) + * [view commit](https://github.com/yahoo/elide/commit/901b41a9991ed87f3fd3030fb517e5dacee2849e) Bump artemis-jms-client-all from 2.25.0 to 2.26.0 (#2799) + * [view commit](https://github.com/yahoo/elide/commit/bd2dbabcdbf657dc19e4c08e8ed3aee43f8bc653) Bump swagger-core from 1.6.6 to 1.6.7 (#2802) + * [view commit](https://github.com/yahoo/elide/commit/d17cd77eb7ced4ac9bb111d4767c17b5b6b48a20) Bump log4j-over-slf4j from 2.0.1 to 2.0.3 (#2801) + * [view commit](https://github.com/yahoo/elide/commit/fc468dbbee59ec85fb079c8cb1d8a1ee8122b9bf) Bump slf4j-api from 2.0.1 to 2.0.3 (#2800) + * [view commit](https://github.com/yahoo/elide/commit/c44438dbfde604307f74c811b863f7d67f3b9b96) Bump artemis-server from 2.25.0 to 2.26.0 (#2803) + * [view commit](https://github.com/yahoo/elide/commit/d1f4aee7ee70809d7db0254eeec1a91b4c651f42) Bump artemis-jms-server from 2.23.1 to 2.26.0 (#2804) + * [view commit](https://github.com/yahoo/elide/commit/13f293723472eec5992d1905277e6058f8f272bb) Bump checkstyle from 10.3.3 to 10.3.4 (#2805) + * [view commit](https://github.com/yahoo/elide/commit/4ce3ae65955783e70b4362153fd99ea204077ca2) Bump junit-platform-commons from 1.9.0 to 1.9.1 (#2806) + * [view commit](https://github.com/yahoo/elide/commit/3afee025f9a3a4c1790632d49438fc955212b558) Refactor expression visitor (#2808) + * [view commit](https://github.com/yahoo/elide/commit/a92efb877d966ddee36f5e01e90a26f410ac88c8) Bump jedis from 4.2.3 to 4.3.0 (#2816) + * [view commit](https://github.com/yahoo/elide/commit/320dfe4b8e1f3fa267c32a42e8221a592e96e02f) Bump swagger-core from 1.6.7 to 1.6.8 (#2823) + * [view commit](https://github.com/yahoo/elide/commit/a1a408dae161fbdee3d11be1b117a872abe08d2b) Bump jackson-databind from 2.13.4 to 2.13.4.2 (#2822) + * [view commit](https://github.com/yahoo/elide/commit/e2f5374e3da59c0bc86cc3cfede78243263feeee) Bump federation-graphql-java-support from 2.0.8 to 2.1.0 (#2821) + * [view commit](https://github.com/yahoo/elide/commit/611c6e1d6e326ab2c7c5a351a46fad32f652ca63) Bump spring.boot.version from 2.7.4 to 2.7.5 (#2827) + * [view commit](https://github.com/yahoo/elide/commit/9420a745a231194e955fcb102f8a82c055abfe0c) Bump micrometer-core from 1.9.4 to 1.9.5 (#2826) + * [view commit](https://github.com/yahoo/elide/commit/61fab948a97d36a0252f96a7f7baceb210ce4f65) Bump mockito-junit-jupiter from 4.8.0 to 4.8.1 (#2825) + * [view commit](https://github.com/yahoo/elide/commit/9a12d3f1b26701328ae2ad327e2f7d3a6000596e) Bump version.jackson from 2.13.4 to 2.14.0 (#2834) + * [view commit](https://github.com/yahoo/elide/commit/283e21320815c09609c418f2b389e77ce54fcb61) Bump checkstyle from 10.3.4 to 10.4 (#2832) + * [view commit](https://github.com/yahoo/elide/commit/cabeef8b1364b5d67a6772b2b7a331298d69d649) Bump federation-graphql-java-support from 2.1.0 to 2.1.1 (#2833) + +## 6.1.8 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/5e8f15f12641310ad9eaf13cc75d33ce6a20780c) Bump spring.boot.version from 2.7.1 to 2.7.2 (#2713) + * [view commit](https://github.com/yahoo/elide/commit/43c330b7ccfb4caccfedf9f4993b64afeb3d31bc) Bump groovy.version from 3.0.11 to 3.0.12 (#2712) + * [view commit](https://github.com/yahoo/elide/commit/fc1fc18876424bab883517bb02b640fd87292a77) Bump graphql-java from 18.2 to 19.0 (#2716) + * [view commit](https://github.com/yahoo/elide/commit/6c289c15299daa540c6b06124aa99c6b84a3e083) Bump version.junit from 5.8.2 to 5.9.0 (#2714) + * [view commit](https://github.com/yahoo/elide/commit/319868c47148d6a7b2f4eca19ac1070303bcaaa2) Bump federation-graphql-java-support from 2.0.3 to 2.0.4 (#2718) + * [view commit](https://github.com/yahoo/elide/commit/2df809abf56195ba4e5a51712e17db848ccbf2b6) Bump mockito-core from 4.4.0 to 4.5.1 (#2632) + * [view commit](https://github.com/yahoo/elide/commit/a7550f78df09338a8442f19cd278a17d7ceece03) Bump gson from 2.9.0 to 2.9.1 (#2719) + * [view commit](https://github.com/yahoo/elide/commit/0c767675adc011cbdebdc685e40f0fe2d104d486) Bump maven-deploy-plugin from 2.8.2 to 3.0.0 (#2717) + * [view commit](https://github.com/yahoo/elide/commit/f61181e6ca5013a7f5e0b16f0d577516561f084f) Bump checkstyle from 10.3.1 to 10.3.2 (#2721) + * [view commit](https://github.com/yahoo/elide/commit/e3fde6733e43866aaa7fb7fbf06f488796a26e19) Bump junit-platform-commons from 1.8.2 to 1.9.0 (#2720) + * [view commit](https://github.com/yahoo/elide/commit/0115043c2c5a623a6d5d4c0d956e79aaab751195) Bump junit-platform-launcher from 1.8.2 to 1.9.0 (#2722) + * [view commit](https://github.com/yahoo/elide/commit/52551b397b74b599700e038bf5a940457f4527f7) Bump calcite-core from 1.30.0 to 1.31.0 (#2725) + * [view commit](https://github.com/yahoo/elide/commit/bbafffc7a232dcf48cd6e72045ab3a326b447af2) Bump artemis-server from 2.23.1 to 2.24.0 in /elide-standalone (#2735) + * [view commit](https://github.com/yahoo/elide/commit/0f095aa80f4973fd9b34df842edfaa61956adff6) Bump artemis-server from 2.23.1 to 2.25.0 (#2743) + * [view commit](https://github.com/yahoo/elide/commit/0f49199050a8736d0d9617008410f882a4897939) Bump federation-graphql-java-support from 2.0.4 to 2.0.6 (#2737) + * [view commit](https://github.com/yahoo/elide/commit/fb82a848e57e925edd26f8d3ae36396bb3c4e6c3) Bump metrics.version from 4.2.10 to 4.2.12 (#2746) + * [view commit](https://github.com/yahoo/elide/commit/c1c747e772a539d3a0411a68fb83e69ed1ae5b32) Bump spring.boot.version from 2.7.2 to 2.7.3 (#2749) + * [view commit](https://github.com/yahoo/elide/commit/a22c178716996d53edb71d6073226cea9ef71e66) Bump javassist from 3.29.0-GA to 3.29.1-GA (#2748) + * [view commit](https://github.com/yahoo/elide/commit/177d878156c20fa8700a88754483784f27988d6e) Bump spring-cloud-context from 3.1.3 to 3.1.4 (#2747) + * [view commit](https://github.com/yahoo/elide/commit/86a95a8061af7457343aa89845cdc82c4f85aeeb) Serdes now take precedence over type converters for enums. (#2755) + * [view commit](https://github.com/yahoo/elide/commit/8de4135dab586c3748a296cf8db4cbbb88a7a0bb) Enforcing ErrorObjects.addError is called prior to any other function calls (#2758) + * [view commit](https://github.com/yahoo/elide/commit/0a5249a5459e250f160c1236f656eeefb8a10d0a) Bump hibernate5.version from 5.6.10.Final to 5.6.11.Final (#2753) + * [view commit](https://github.com/yahoo/elide/commit/d81fc6c0c210c47ee69eabb81012a3c87a3446a1) Bump maven-site-plugin from 3.12.0 to 3.12.1 (#2752) + * [view commit](https://github.com/yahoo/elide/commit/891ba6c540abba58fd4b0d88fb4dd8d68a1ee4bd) Bump mockito-core from 4.6.1 to 4.8.0 (#2750) + * [view commit](https://github.com/yahoo/elide/commit/c0a0f0ad9a1a9bbb9938e3225f27b2d191b82e79) Only build query runner if graphQL is enabled (#2766) + * [view commit](https://github.com/yahoo/elide/commit/e9f41ad0b78260530abb186ca08e69eaa6b68eda) Bump mockito-junit-jupiter from 4.6.1 to 4.8.0 (#2765) + * [view commit](https://github.com/yahoo/elide/commit/f5dfa505b0b8bfe617151ea6e88b821258d169a9) Bump log4j-to-slf4j from 2.18.0 to 2.19.0 (#2768) + * [view commit](https://github.com/yahoo/elide/commit/42462aa6dce5a1db69d8dbc4e605a3bd1702087f) Bump log4j-over-slf4j from 1.7.36 to 2.0.1 (#2764) + * [view commit](https://github.com/yahoo/elide/commit/16f463c037d78b8a9881e4b571120510c4634d1f) Bump jackson-databind from 2.13.3 to 2.13.4 (#2762) + * [view commit](https://github.com/yahoo/elide/commit/8a68521dbc4d3278e922d7c6bf26e729b0041057) Bump log4j-api from 2.18.0 to 2.19.0 (#2769) + * [view commit](https://github.com/yahoo/elide/commit/a8630a2d8997e90f33d1dc6bea8f8f58f567a0d0) Bump version.restassured from 5.1.1 to 5.2.0 (#2754) + * [view commit](https://github.com/yahoo/elide/commit/e44bed7bf161799b9afc9c05e2e664a5ecddb3f2) Bump spring-core from 5.3.22 to 5.3.23 (#2771) + * [view commit](https://github.com/yahoo/elide/commit/9718a65c0795456c9be1dfa19d0cee7cf52d6c1e) Bump spring-websocket from 5.3.22 to 5.3.23 (#2770) + * [view commit](https://github.com/yahoo/elide/commit/7ff9de94a72fb6916ed41bf119fcb6c5afca9d46) Bump snakeyaml from 1.31 to 1.32 (#2772) + * [view commit](https://github.com/yahoo/elide/commit/6adc3d48b49523cd9c7d116139e6fdc93632034b) Bump federation-graphql-java-support from 2.0.6 to 2.0.7 (#2773) + * [view commit](https://github.com/yahoo/elide/commit/06e4ac84e83240450e123f983b240c8969431114) Bump maven-jar-plugin from 3.2.2 to 3.3.0 (#2774) + * [view commit](https://github.com/yahoo/elide/commit/cc5146bc328885860e82baf72ea0a0efc59e2c0e) Bump maven-checkstyle-plugin from 3.1.2 to 3.2.0 (#2778) + * [view commit](https://github.com/yahoo/elide/commit/24da916438daa003492c8b0deeb2e52c3a502b35) Bump slf4j-api from 1.7.36 to 2.0.1 (#2777) + * [view commit](https://github.com/yahoo/elide/commit/70ecd9d72eae3e7697220cc1267e5a2ea5b3506a) Bump maven-javadoc-plugin from 3.4.0 to 3.4.1 (#2776) + * [view commit](https://github.com/yahoo/elide/commit/973ac12fb93e7d2f2b295ec0ed41ad5d83b785d1) Bump checkstyle from 10.3.2 to 10.3.3 (#2775) + * [view commit](https://github.com/yahoo/elide/commit/357f654e476c098da845b6fea843dec968d756ce) Bump dependency-check-maven from 7.1.1 to 7.2.0 (#2779) + * [view commit](https://github.com/yahoo/elide/commit/5439c085f9714302f428e39a6aba721c58b6e04e) Bump javassist from 3.29.1-GA to 3.29.2-GA (#2780) + * [view commit](https://github.com/yahoo/elide/commit/729e74d2f69fc44897d661610ac4390fd16950ec) Bump micrometer-core from 1.9.2 to 1.9.4 (#2781) + +## 6.1.7 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/53fbf01a9e7d0120db65334e99ccae5c7cf78c5e) Use Serializable Classes for RedisCache to work (#2681) + * [view commit](https://github.com/yahoo/elide/commit/1c8cafba49a5b752c586ab2271e92edf6021a20e) Upgrade to Jetty 10.0.10 (#2691) + * [view commit](https://github.com/yahoo/elide/commit/8f18657ee0db4354b6e408610508b432bdaeb395) Bump dependency-check-maven from 7.1.0 to 7.1.1 (#2678) + * [view commit](https://github.com/yahoo/elide/commit/7071100dab77f1923743927687908e5f339afeab) Fixed query builder so that it does not treat embedded properties as … (#2686) + * [view commit](https://github.com/yahoo/elide/commit/98e3c00066ed0f07baf0a3e9524cfae2af990db5) Bump maven-enforcer-plugin from 3.0.0 to 3.1.0 (#2677) + * [view commit](https://github.com/yahoo/elide/commit/ece798a49aee31b4c31d67a9835b3ebed8feebc3) Bump version.restassured from 5.1.0 to 5.1.1 (#2676) + * [view commit](https://github.com/yahoo/elide/commit/839fa5c5346d617c3cf830deaad0a0a27591f05d) Bump metrics.version from 4.2.9 to 4.2.10 (#2692) + * [view commit](https://github.com/yahoo/elide/commit/45be05acd42b207a323e551eddf197992f50bc8d) Bump spring.boot.version from 2.7.0 to 2.7.1 (#2693) + * [view commit](https://github.com/yahoo/elide/commit/9f17574d9ce3e7abe2156884bce20828a4d9ebf1) Bump checkstyle from 10.3 to 10.3.1 (#2694) + * [view commit](https://github.com/yahoo/elide/commit/6fd828dbeea64c8b34b7529472e5b20f5571508c) Bump classgraph from 4.8.147 to 4.8.149 (#2696) + * [view commit](https://github.com/yahoo/elide/commit/72fd34bcf1ba9b00900c3374fd98f10270adcd80) Bump hibernate5.version from 5.6.9.Final to 5.6.10.Final (#2697) + * [view commit](https://github.com/yahoo/elide/commit/1be66da9306e4f672727724a1a4fe1f88dd9eea9) Bump log4j-to-slf4j from 2.17.2 to 2.18.0 (#2699) + * [view commit](https://github.com/yahoo/elide/commit/e9eca4e0e9b38be62ecc3a53128ac19daef1df9e) Bump log4j-api from 2.17.2 to 2.18.0 (#2698) + * [view commit](https://github.com/yahoo/elide/commit/46056c7f2ead1278b970c0adaa154d99ac3e63d8) Bump artemis-jms-client-all from 2.22.0 to 2.23.1 (#2700) + * [view commit](https://github.com/yahoo/elide/commit/dd34555e43e9e54a2740edbaa60e488d21515171) Bump micrometer-core from 1.9.0 to 1.9.2 (#2695) + * [view commit](https://github.com/yahoo/elide/commit/dd74e410e0c7a6fa7d93145f9f565342446e542a) Bump federation-graphql-java-support from 2.0.1 to 2.0.3 (#2701) + * [view commit](https://github.com/yahoo/elide/commit/1c436b544dd056856db036f3e1bed201fc700baf) Bump artemis-server from 2.22.0 to 2.23.1 (#2702) + * [view commit](https://github.com/yahoo/elide/commit/7ebfb11f77ebdfa49bf9e92b090a23c0136e559f) Bump spring-websocket from 5.3.20 to 5.3.22 (#2704) + * [view commit](https://github.com/yahoo/elide/commit/7a5700f4614063b205a8c4bd443a606c0eff303d) Bump wagon-ssh-external from 3.5.1 to 3.5.2 (#2705) + * [view commit](https://github.com/yahoo/elide/commit/40324b5a1615ae7ffee23a4bedc1f64272d244e4) Bump h2 from 2.1.212 to 2.1.214 (#2707) + * [view commit](https://github.com/yahoo/elide/commit/879421c3ad94a03d24c5390860a36e3211c32cdb) Bump artemis-jms-server from 2.22.0 to 2.23.1 (#2706) + * [view commit](https://github.com/yahoo/elide/commit/1a056ac5b7d5b1bd88e0142481b16755573a45e1) Bump graphql-java from 18.1 to 18.2 (#2708) + * [view commit](https://github.com/yahoo/elide/commit/bd82030a3ac64882f2a67282b9bf4159b21d817c) Bump spring-core from 5.3.20 to 5.3.22 (#2709) + * [view commit](https://github.com/yahoo/elide/commit/e49202d2c541f35c1d41457a51c3c7a145c75fce) Bump jsonassert from 1.5.0 to 1.5.1 (#2710) + +## 6.1.6 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/af3e1d9d64479e3ca0f5141bec8b906843d11248) Minimum to expose _service.sdl for Apollo federation. (#2640) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/ba7faef135fe9085e9959d2bb286574cf7e2a843) Adding explicit discord link. + * [view commit](https://github.com/yahoo/elide/commit/8adc4439ded747d4dd1191ad5fed6131bde39cb6) Add Injector Bean (#2637) + * [view commit](https://github.com/yahoo/elide/commit/d8cdbb9204a27c6083473f6433bda076384f978c) Bump caffeine from 3.0.6 to 3.1.0 (#2643) + * [view commit](https://github.com/yahoo/elide/commit/cb500d13439d63b267e6219e35e672d7d32b3b82) Bump spring-cloud-context from 3.1.1 to 3.1.2 (#2642) + * [view commit](https://github.com/yahoo/elide/commit/6eb767cd30faa745479bb8910de3937ba5eed971) Bump graphql-java from 18.0 to 18.1 (#2645) + * [view commit](https://github.com/yahoo/elide/commit/4b3f318fdb6486185ecb17bcde47f3b32d461df0) Bump jedis from 4.2.2 to 4.2.3 (#2646) + * [view commit](https://github.com/yahoo/elide/commit/ae34fede3835c107617dbc448ce32573ad5ec8e8) Issue2650 (#2653) + * [view commit](https://github.com/yahoo/elide/commit/26669553093b2d85b385c7298fd53a7225c4cf4d) Bump spring-core from 5.3.19 to 5.3.20 in /elide-model-config (#2654) + * [view commit](https://github.com/yahoo/elide/commit/2b5759ac2fc589f2da9a08c45577f28d37f4e4ad) Bump federation-graphql-java-support from 2.0.0-alpha.5 to 2.0.1 (#2655) + * [view commit](https://github.com/yahoo/elide/commit/c5583f4a79fe501742d57dc9a2b65078f870d8f3) Bump javassist from 3.28.0-GA to 3.29.0-GA (#2651) + * [view commit](https://github.com/yahoo/elide/commit/9dd4ffc25d8318edcf167d10e4dd6e053d0061d0) Bump artemis-jms-server from 2.21.0 to 2.22.0 (#2644) + * [view commit](https://github.com/yahoo/elide/commit/a7d8c2435177faadfe97554dc107fd822025b6c9) Bump artemis-server from 2.21.0 to 2.22.0 (#2647) + * [view commit](https://github.com/yahoo/elide/commit/a2e9e205351b090c1186fd03298e1d5f86591c99) Bump version.restassured from 5.0.1 to 5.1.0 (#2658) + * [view commit](https://github.com/yahoo/elide/commit/403132f7569c2b568b38f653bd1a08c681e9d53f) Bump micrometer-core from 1.8.5 to 1.9.0 (#2657) + * [view commit](https://github.com/yahoo/elide/commit/dfcb6a3cd5a3748259380f58d525eeda6fb1d4f1) Bump hibernate5.version from 5.6.8.Final to 5.6.9.Final (#2659) + * [view commit](https://github.com/yahoo/elide/commit/144909516ffa9e99d7a6f1cb8c86d8737b2603bf) Bump spring.boot.version from 2.6.8 to 2.7.0 (#2663) + * [view commit](https://github.com/yahoo/elide/commit/b24f7aa63971d67def337add1dab69cd6cf80508) Bump mockito-junit-jupiter from 4.5.1 to 4.6.1 (#2661) + * [view commit](https://github.com/yahoo/elide/commit/42ce8325fcd0719b5d2d47c263a668b08b332a87) Bump caffeine from 3.1.0 to 3.1.1 (#2660) + * [view commit](https://github.com/yahoo/elide/commit/c806f4b6373277b9f7c5b1841dc01811cf1b21c8) Bump spring-cloud-context from 3.1.2 to 3.1.3 (#2666) + * [view commit](https://github.com/yahoo/elide/commit/fe1615198a05f18f73e08f64218eabd02da27660) Bump jackson-databind from 2.13.2.2 to 2.13.3 (#2665) + * [view commit](https://github.com/yahoo/elide/commit/f0d5019d7a2dd96643022bb37f45b882cab2f3bd) Bump maven-surefire-plugin from 3.0.0-M6 to 3.0.0-M7 (#2664) + * [view commit](https://github.com/yahoo/elide/commit/c9da5e7e3ede34269de31eef55e2362790e9a050) Bump version.jackson from 2.13.2 to 2.13.3 (#2662) + * [view commit](https://github.com/yahoo/elide/commit/e7cb5a5bf3e3f8f5b0f02331b079270605959324) Bump maven-scm-api from 1.12.2 to 1.13.0 (#2667) + * [view commit](https://github.com/yahoo/elide/commit/9cd66ef30f62bd5bced39d7cedb487deb759324e) Bump maven-scm-provider-gitexe from 1.12.2 to 1.13.0 (#2668) + * [view commit](https://github.com/yahoo/elide/commit/44bcaa3eb7af39ec8d33eba863fdca467e1d1a7c) Bump graphql-java-extended-scalars from 18.0 to 18.1 (#2669) + * [view commit](https://github.com/yahoo/elide/commit/0602ae73e28a0733b8d0fd77990d6754993bc394) Bump groovy.version from 3.0.10 to 3.0.11 (#2670) + * [view commit](https://github.com/yahoo/elide/commit/a01f79633bee592f4cce5c31d8adaba62974553c) Bump checkstyle from 10.2 to 10.3 (#2671) + * [view commit](https://github.com/yahoo/elide/commit/0cac28a628c89f5c2ae60dd3c7f77a7833d8e3fb) Bump artemis-jms-client-all from 2.21.0 to 2.22.0 (#2672) + * [view commit](https://github.com/yahoo/elide/commit/6175753e11e39cadf5075eec72e16dee4c0f23c1) Bump classgraph from 4.8.146 to 4.8.147 (#2673) + +## 6.1.5 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/d7b8635bcba15ce8103e0cebd42ee6825dfa982d) Aggregation store dynamic table sql (#2626) + * [view commit](https://github.com/yahoo/elide/commit/abb32869ab0e97b8ceaa1bd61b53c579b4ca1c65) Header filtering config (#2627) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/c8dd203ff2dd7aa772c25f66b85b694d428ead08) Bump h2 from 2.1.210 to 2.1.212 (#2606) + * [view commit](https://github.com/yahoo/elide/commit/18df59e522559aa6f9329c2127096ab3c8c8bb9e) Fixes #2615 (#2616) + * [view commit](https://github.com/yahoo/elide/commit/2aa70c7433ff601eeb94711175d0c7fe5cb08c43) Removed unnecessary conversion to JsonNode when serializing JSON-API … (#2618) + * [view commit](https://github.com/yahoo/elide/commit/6a5f8bc3d6eba21182f3eafa5c2b996492430091) Bump spring-core from 5.3.18 to 5.3.19 (#2609) + * [view commit](https://github.com/yahoo/elide/commit/e15ec1cbb44ac64a4b2ee13798a60cf398ed81ec) Bump spring-websocket from 5.3.18 to 5.3.19 (#2611) + * [view commit](https://github.com/yahoo/elide/commit/7dca40789acb914af4ba8c9c5ea287b819dd7cd8) Bump classgraph from 4.8.143 to 4.8.146 (#2610) + * [view commit](https://github.com/yahoo/elide/commit/ec67bf5cda12410178ada8064faf28e2a936cfc9) Bump lombok from 1.18.22 to 1.18.24 (#2614) + * [view commit](https://github.com/yahoo/elide/commit/9d5b98b5d41e8676f149ce1669d1826b766acd1b) fix: support big number aggregations (#2628) + * [view commit](https://github.com/yahoo/elide/commit/c0a05167ad9caa5b1a55dfac08f4664805825e4c) Bump maven-javadoc-plugin from 3.3.2 to 3.4.0 (#2623) + * [view commit](https://github.com/yahoo/elide/commit/35edc6b4b61060e64c428cdbaa8089eec3e5bee6) Bump jedis from 4.2.1 to 4.2.2 (#2621) + * [view commit](https://github.com/yahoo/elide/commit/625e0eb638fc099472607532b46caaef59e30c64) Bump hibernate5.version from 5.6.7.Final to 5.6.8.Final (#2613) + * [view commit](https://github.com/yahoo/elide/commit/818f959ecff6d493942a12923eaac436ef9d16cd) Bump maven-site-plugin from 3.11.0 to 3.12.0 (#2629) + * [view commit](https://github.com/yahoo/elide/commit/f6d3b687dc287623cfcf19254a7c166f467866f4) Bump micrometer-core from 1.8.4 to 1.8.5 (#2624) + * [view commit](https://github.com/yahoo/elide/commit/263beb90c0ea2270937fc7429ba44735fa3b84cb) Bump spring.boot.version from 2.6.6 to 2.6.7 (#2631) + * [view commit](https://github.com/yahoo/elide/commit/d8e2a86e0793724714c606ea43ec07a9292db636) Bump graphql-java-extended-scalars from 17.0 to 18.0 (#2630) + * [view commit](https://github.com/yahoo/elide/commit/e4e14fdcf219d557ff71c6bf3cfedeaa2ef84f9e) Bump nexus-staging-maven-plugin from 1.6.12 to 1.6.13 (#2634) + * [view commit](https://github.com/yahoo/elide/commit/886262b597e64bc4e03c057cd0843f717fffd8e1) Bump mockito-junit-jupiter from 4.4.0 to 4.5.1 (#2633) + * [view commit](https://github.com/yahoo/elide/commit/dca470023c7a34a5d0b5d4dfd969493c0fccdb1b) Bump checkstyle from 10.1 to 10.2 (#2635) + * [view commit](https://github.com/yahoo/elide/commit/7762d7f09e02a24b2c1ccb302c6a6f6c734c5512) Bump dependency-check-maven from 7.0.4 to 7.1.0 (#2636) + +## 6.1.4 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/e94d36ad948a540f7a45af858b7b44d554f8656a) Bump spring-core from 5.3.16 to 5.3.18 in /elide-model-config (#2572) + * [view commit](https://github.com/yahoo/elide/commit/aec37097a746370ff66b4f61179b6e6934be4d32) Revert "Update: Make `-` a valid TEXT value type character (#2565)" (#2581) + * [view commit](https://github.com/yahoo/elide/commit/95f94a9c32b3ef666134f33954118a846ec6acde) Bumping Spring to work around CVE (#2583) + * [view commit](https://github.com/yahoo/elide/commit/78fe93d45db5300760a1f82be0acd733d442c162) Added explicit tests for SQL comment dependency injection for Aggregation Store. (#2582) + * [view commit](https://github.com/yahoo/elide/commit/95f183de6c3a72b6101f734790b8ef11a73d25f0) Bump version.logback from 1.2.10 to 1.2.11 (#2580) + * [view commit](https://github.com/yahoo/elide/commit/d840018519a560a3cdff463753b29ace42a14c8f) Bump artemis-jms-client-all from 2.20.0 to 2.21.0 (#2578) + * [view commit](https://github.com/yahoo/elide/commit/1c5d17363d7802ff1f2f2297aac1bd5247f26b45) Bump spring-websocket from 5.3.16 to 5.3.18 (#2573) + * [view commit](https://github.com/yahoo/elide/commit/ba0202fceecb20dfb9657344d500581d5ca1d09c) Bump guava from 31.0.1-jre to 31.1-jre (#2577) + * [view commit](https://github.com/yahoo/elide/commit/5b741bdfe6e63eae8f6393d04acd609e0f8891f6) Bump maven-surefire-plugin from 3.0.0-M5 to 3.0.0-M6 (#2579) + * [view commit](https://github.com/yahoo/elide/commit/28f3be8d6bf08d4a71e6309d5a1aaacb1c584314) Bump version.jetty from 9.4.45.v20220203 to 9.4.46.v20220331 (#2576) + * [view commit](https://github.com/yahoo/elide/commit/a78159a6fea97bb9dfdf54d9cc532d4357d4e2c9) Bump swagger-core from 1.6.5 to 1.6.6 (#2587) + * [view commit](https://github.com/yahoo/elide/commit/284597a59355afee97d612a0609be63227a2f737) Bump log4j-api from 2.17.1 to 2.17.2 (#2588) + * [view commit](https://github.com/yahoo/elide/commit/90e6f1aa230efeedb34fe3e2d715ae8884e756b5) Bump calcite-core from 1.29.0 to 1.30.0 (#2586) + * [view commit](https://github.com/yahoo/elide/commit/58dcf77e1fba9df8b36d0b03fc5469090a46a2f3) Bump metrics.version from 4.2.8 to 4.2.9 (#2585) + * [view commit](https://github.com/yahoo/elide/commit/106b3e19ef2888ce3b300f76b65d0a33c3740425) Bump checkstyle from 9.3 to 10.1 (#2589) + * [view commit](https://github.com/yahoo/elide/commit/a9e6d6d7877e4e9f7f13b8b44ced26f2c26241da) Bump graphql-java from 17.3 to 18.0 (#2592) + * [view commit](https://github.com/yahoo/elide/commit/ab3afd37c594c6b3ccd68e095fcd150227892585) Bump jacoco-maven-plugin from 0.8.7 to 0.8.8 (#2593) + * [view commit](https://github.com/yahoo/elide/commit/925b02523bd784c3fc02c11f31684f9927055bec) Bump dependency-check-maven from 6.5.3 to 7.0.4 (#2591) + * [view commit](https://github.com/yahoo/elide/commit/38827720a6824b9a10532610b6aca41a78dd5ff3) Bump artemis-jms-server from 2.20.0 to 2.21.0 (#2590) + * [view commit](https://github.com/yahoo/elide/commit/acee8737a326dd4573ae4154582dc391b02c5eb7) Bump version.restassured from 4.4.0 to 5.0.1 (#2584) + * [view commit](https://github.com/yahoo/elide/commit/b9ad7bf4b3097ba840230bb184733ba4eb06256b) Bump classgraph from 4.8.139 to 4.8.143 (#2598) + * [view commit](https://github.com/yahoo/elide/commit/15660637f034bd357d8216de983933cfb7b658ed) Bump mockito-junit-jupiter from 4.3.1 to 4.4.0 (#2597) + * [view commit](https://github.com/yahoo/elide/commit/52d407fb6b946676f013ab8a19f182e02d05b5cf) Bump mockito-core from 4.3.1 to 4.4.0 (#2594) + * [view commit](https://github.com/yahoo/elide/commit/f585b6a212a7a9364a63b994419c30a7eb637e7a) Bump caffeine from 3.0.5 to 3.0.6 (#2595) + * [view commit](https://github.com/yahoo/elide/commit/880e88a4ca5234ee9b4b94924000542072dbfc99) Bump artemis-server from 2.20.0 to 2.21.0 (#2600) + * [view commit](https://github.com/yahoo/elide/commit/2f4912f96ff518727e68d91d0abcadf4de9b9161) Bump jedis from 4.1.1 to 4.2.1 (#2599) + * [view commit](https://github.com/yahoo/elide/commit/1f1a58e4b19a93bb38a01699e4d8a4cd6ff8ab4f) Bump jackson-databind from 2.13.2.1 to 2.13.2.2 (#2603) + * [view commit](https://github.com/yahoo/elide/commit/bfcabe310e29691b6ce87c88cd75165c72952987) Bump maven-compiler-plugin from 3.10.0 to 3.10.1 (#2602) + * [view commit](https://github.com/yahoo/elide/commit/f872d11a8b12bbcf28f42327813981de7325b4a6) Bump micrometer-core from 1.8.3 to 1.8.4 (#2596) + * [view commit](https://github.com/yahoo/elide/commit/a2578aa8eeb4ba218eaaddb45f57b9b2e15c072d) Fixes #2601 (#2604) + +## 6.1.3 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/1a644f6f5afec9f0de8dbf89a56006d9f5b3058a) Bump log4j-to-slf4j from 2.17.1 to 2.17.2 (#2560) + * [view commit](https://github.com/yahoo/elide/commit/d2c906859486a1837ebca6add7d7ec881380b9ea) Bump version.jackson from 2.12.5 to 2.13.1 (#2527) + * [view commit](https://github.com/yahoo/elide/commit/588b69f120c8e914da4becc3f8b83b4d8b71d92e) Update: Make `-` a valid TEXT value type character (#2565) + * [view commit](https://github.com/yahoo/elide/commit/5b7051da2a0cc9b97b2dd29c5b95a04a794a6901) Bump spring.boot.version from 2.6.3 to 2.6.5 (#2566) + * [view commit](https://github.com/yahoo/elide/commit/5e67e547d60483696508e2e90460eee21c391491) Fix graphiql comment bug and config store issues creating multiple files in a single request. (#2571) + * [view commit](https://github.com/yahoo/elide/commit/14ae1c4f31914f44ac3f347a80235947da3231f7) Bump jackson-databind from 2.13.2 to 2.13.2.1 (#2569) + * [view commit](https://github.com/yahoo/elide/commit/78c636ed19e57a98e84243689dcf6f14c054681d) Bump hibernate5.version from 5.6.5.Final to 5.6.7.Final (#2568) + * [view commit](https://github.com/yahoo/elide/commit/cf3bbfed579710352e4501487f7939a162a13c9c) Bump groovy.version from 3.0.9 to 3.0.10 (#2567) + * [view commit](https://github.com/yahoo/elide/commit/507ba6ced3ef2ef68c484541481f9c5b7499f342) Bump nexus-staging-maven-plugin from 1.6.11 to 1.6.12 (#2559) + * [view commit](https://github.com/yahoo/elide/commit/c7d44ce56eee1f0dced6afc1520fd93309614f6e) Bump spring-cloud-context from 3.1.0 to 3.1.1 (#2557) + +## 6.1.2 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/1355701e30f63ba7332e4b77efdf5166e21a3e61) Bump json-path from 2.6.0 to 2.7.0 (#2531) + * [view commit](https://github.com/yahoo/elide/commit/1d105705f533d40b55469f3b6836935995e982b5) Ignoring Elide models embedded in complex attributes (#2535) + * [view commit](https://github.com/yahoo/elide/commit/76a45fc895901fa212883e88a9be29fdad894b7c) Bump version.jetty from 9.4.44.v20210927 to 9.4.45.v20220203 (#2534) + * [view commit](https://github.com/yahoo/elide/commit/0d1be94716765581ab230b5b1d77906d3882b283) Bump jedis from 4.1.0 to 4.1.1 (#2530) + * [view commit](https://github.com/yahoo/elide/commit/73d2ce87b2af2a0dd2c4ad800030724d76f085e0) Bump mockito-core from 4.2.0 to 4.3.1 (#2518) + * [view commit](https://github.com/yahoo/elide/commit/7367573a0aba833d1e1415b3ce74088e7ad695f0) Bump mockito-junit-jupiter from 4.2.0 to 4.3.1 (#2517) + * [view commit](https://github.com/yahoo/elide/commit/72954a50f4f1e02cb257f9e7224491f1b3bef7a5) Bump spring.boot.version from 2.6.2 to 2.6.3 (#2510) + * [view commit](https://github.com/yahoo/elide/commit/8a0cc1d198eb36d2e5681c44143230a969745534) Bump spring-cloud-context from 3.0.4 to 3.1.0 (#2512) + * [view commit](https://github.com/yahoo/elide/commit/6409cb61e9555c6331f7e1b353456a0a11c51962) Bump classgraph from 4.8.138 to 4.8.139 (#2541) + * [view commit](https://github.com/yahoo/elide/commit/b2c48a1dad67b1dbf28c80c4a9286360ea52ec91) Bump metrics.version from 4.2.7 to 4.2.8 (#2540) + * [view commit](https://github.com/yahoo/elide/commit/cb3d4c05f60828757e239f75be13793d52841cbc) Bump maven-compiler-plugin from 3.9.0 to 3.10.0 (#2542) + * [view commit](https://github.com/yahoo/elide/commit/a91983785f2f7c87857d13e5e64a00dd45a6c7f9) Bump log4j-over-slf4j from 1.7.35 to 1.7.36 (#2547) + * [view commit](https://github.com/yahoo/elide/commit/d3377acdd7dd94f0919f305c3f19a5851ea1f342) Bump checkstyle from 9.2.1 to 9.3 (#2546) + * [view commit](https://github.com/yahoo/elide/commit/a7f654cd10b74d452a08e6a9c12cdf7239eb3acc) Bump swagger-core from 1.6.4 to 1.6.5 (#2549) + * [view commit](https://github.com/yahoo/elide/commit/43db0ba55f34e0052ac264fe95e676d4e3463d0d) Bump maven-javadoc-plugin from 3.3.1 to 3.3.2 (#2544) + * [view commit](https://github.com/yahoo/elide/commit/cc1855fee8f236ffbdd90c21a30c8b15fe5f6d59) Bump nexus-staging-maven-plugin from 1.6.8 to 1.6.11 (#2548) + * [view commit](https://github.com/yahoo/elide/commit/dfa92a6b48532ce43a5518eefb57df19d73385de) Bump micrometer-core from 1.8.2 to 1.8.3 (#2545) + * [view commit](https://github.com/yahoo/elide/commit/ecce96462be68acbf95f3ba73f0c8ba3d46ed9a1) Bump maven-site-plugin from 3.10.0 to 3.11.0 (#2543) + * [view commit](https://github.com/yahoo/elide/commit/8d70b62b4650b9c7c9d6dd7276306301dca5839d) Bump spring-websocket from 5.3.15 to 5.3.16 (#2553) + * [view commit](https://github.com/yahoo/elide/commit/4b31a018ec4abac13cf9238f78e585857c465195) Bump gson from 2.8.9 to 2.9.0 (#2552) + * [view commit](https://github.com/yahoo/elide/commit/3c5932522440ea4e0298e55bbb8eebfdd5a06ba6) Bump slf4j-api from 1.7.35 to 1.7.36 (#2550) + * [view commit](https://github.com/yahoo/elide/commit/69056c88fa6dbb13de0a667f5c50defcdc34093d) Bump spring-core from 5.3.15 to 5.3.16 (#2554) + +## 6.1.1 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/6646ce31a465a6648dcb74fb96aeb5c6d39a22bc) Redis Cache for Aggregation Store (#25 + * [view commit](https://github.com/yahoo/elide/commit/df5d023f43876264b63fd3becfbff57a4e889789) Redis Result Storage Engine (#2507) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/9c9d4ca1fa15047e24fffcf2a7e0e7a9a46e6791) Disabling subscription scanning throug +h application yaml in Spring. (#2521) + * [view commit](https://github.com/yahoo/elide/commit/c038599b4f4d1d7b9a688365e0157425daf19c5b) Bump h2 from 2.0.206 to 2.1.210 (#2508 +) + * [view commit](https://github.com/yahoo/elide/commit/3e69dabdd9479a8c070b811299ab535f5ba7a1db) Bump hibernate5.version from 5.6.1.Fin +al to 5.6.5.Final (#2516) + * [view commit](https://github.com/yahoo/elide/commit/75db2ed70dc6f00368a626d3e2bde584699c8fc1) Bump log4j-over-slf4j from 1.7.33 to 1 +.7.35 (#2522) + + * [view commit](https://github.com/yahoo/elide/commit/b932cbcba8c9ba993a4fe5b3f859d5691fd976b4) Bump guice from 5.0.1 to 5.1.0 (#2523) + + * [view commit](https://github.com/yahoo/elide/commit/fd2ab7709a90f183ee09cbbec6628af49c003ef5) Bump jedis from 4.0.1 to 4.1.0 (#2525) + + * [view commit](https://github.com/yahoo/elide/commit/d53275d1fb55de45a62269f39e97f0cdd76b573e) Bump slf4j-api from 1.7.33 to 1.7.35 ( +#2519) + * [view commit](https://github.com/yahoo/elide/commit/d0552c5750608391d52e3e3286469901726a7d06) Support filters on query plans (#2526) + +## 6.1.0 +Minor release update for partial support for Elide configuration being 'refreshable' in Spring for a live service. Advanced users overriding some beans may require compilation changes. + +**Features** + * [view commit](https://github.com/yahoo/elide/commit/c38eb980af7f953202cb53faaed14595d3709ed9) Refresh scope beans (#2409) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/833ea32e4a793bd1c7da1d1ad73417c216b1191a) [maven-release-plugin] prepare for nex +t development iteration + * [view commit](https://github.com/yahoo/elide/commit/7a3552d76f8887ab62a60a968412ca442bf745a1) make getColumnProjection() method use +different name for column Id for different source when alias are involved (#2500) + * [view commit](https://github.com/yahoo/elide/commit/4c9bff306e4d0d0f9644c705a241edc7104cc1fe) Bump h2 from 2.0.202 to 2.0.206 (#2476 +) + * [view commit](https://github.com/yahoo/elide/commit/c68b44abdf0b7465695f3fbc347cf3c044f4f1d5) Bump micrometer-core from 1.8.1 to 1.8 +.2 (#2505) + * [view commit](https://github.com/yahoo/elide/commit/cc8d202e113eaa319c2922e86d86d4a8eab31b41) Bump spring-core from 5.3.14 to 5.3.15 + (#2504) + * [view commit](https://github.com/yahoo/elide/commit/df1dfca185d72ce383b84d5ee803534d7ca16dd3) Bump spring-websocket from 5.3.14 to 5 +.3.15 (#2503) + * [view commit](https://github.com/yahoo/elide/commit/2059c44511bdc4294dd5667baa7c0eed19bc0075) Bump slf4j-api from 1.7.32 to 1.7.33 ( +#2502) + * [view commit](https://github.com/yahoo/elide/commit/5718774759f42be1b6b4f34c962d2b64457c3193) Bump log4j-over-slf4j from 1.7.32 to 1 +.7.33 (#2501) + +## 6.0.7 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/7a3552d76f8887ab62a60a968412ca442bf745a1) make getColumnProjection() method use different name for column Id for different source when alias are involved (#2500) + * [view commit](https://github.com/yahoo/elide/commit/4c9bff306e4d0d0f9644c705a241edc7104cc1fe) Bump h2 from 2.0.202 to 2.0.206 (#2476) + +## 6.0.6 +**Features** + * [view commit](https://github.com/yahoo/elide/commit/4caaae234214edf6a61bea57531de79520604d54) File Extension Support for Export Attachments (#2475) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/93baacfea6628cc724b7fb880326a702651b939b) Make CacheKey Unique for same model name across namespaces (#2477) + * [view commit](https://github.com/yahoo/elide/commit/c38857cd07318d5f09b9b087056d627ab01bf156) Cleanup code warnings (#2473) + * [view commit](https://github.com/yahoo/elide/commit/ca46fc6726ed161767d46e1060566ca234cdec5e) Bump build-helper-maven-plugin from 3.2.0 to 3.3.0 (#2481) + * [view commit](https://github.com/yahoo/elide/commit/0f0e94b66532890d4f2ed9f7f34bf7499192d73d) Bump HikariCP from 5.0.0 to 5.0.1 (#2484) + * [view commit](https://github.com/yahoo/elide/commit/c8e3aad63bbdef64c32baf9c538f66e298e1e19a) Bump checkstyle from 9.2 to 9.2.1 (#2485) + * [view commit](https://github.com/yahoo/elide/commit/d13c7b78872df5d4ed7c083dd3883502d4133d8c) Bump maven-site-plugin from 3.9.1 to 3.10.0 (#2480) + * [view commit](https://github.com/yahoo/elide/commit/a5f46a0bb816ab57c43c9b9535d9fd2b8aeb088f) Bump maven-scm-api from 1.12.0 to 1.12.2 (#2479) + * [view commit](https://github.com/yahoo/elide/commit/adb52de7d01eac1e53174d6b7fdc872b7bc81b92) Bump wagon-ssh-external from 3.4.3 to 3.5.1 (#2478) + * [view commit](https://github.com/yahoo/elide/commit/fd9ebf4ffa3a625df38840df723ac1c43605eea9) Bump artemis-jms-client-all from 2.19.0 to 2.20.0 (#2482) + * [view commit](https://github.com/yahoo/elide/commit/e090ab5ed5344760d6239c680515f203548c1596) use alias to get column projection in query plan translator and while nesting projection (#2493) + * [view commit](https://github.com/yahoo/elide/commit/7bf5de6d098acb36b60d40619d35a536cfe1a4ee) Aggregation Store: Fix filter by alias with parameterized metric. (#2494) + * [view commit](https://github.com/yahoo/elide/commit/7f7d029924799a40b07e60f6aaead95cade0136d) Bump maven-jar-plugin from 3.2.0 to 3.2.1 (#2492) + * [view commit](https://github.com/yahoo/elide/commit/8ec4c15d5c04670d364925f390ac1eff6f5bbf3f) Bump log4j-to-slf4j from 2.17.0 to 2.17.1 (#2487) + * [view commit](https://github.com/yahoo/elide/commit/5dcf985d17c967c8882eb23e72a1d1764f53e7dd) Bump swagger-core from 1.6.3 to 1.6.4 (#2488) + * [view commit](https://github.com/yahoo/elide/commit/faf7c68aaf907d2601a0151e7723580369280883) Bump mockito-junit-jupiter from 4.1.0 to 4.2.0 (#2496) + * [view commit](https://github.com/yahoo/elide/commit/5e337f4f3e28642e08daa971b567926594a5f10e) Bump artemis-server from 2.19.0 to 2.20.0 (#2495) + * [view commit](https://github.com/yahoo/elide/commit/493d048bc23c3fdd5029d6db2ff92f84f4368902) Bump artemis-jms-server from 2.19.0 to 2.20.0 (#2491) + * [view commit](https://github.com/yahoo/elide/commit/a074e01864cc7834766e3571e60e0a4c5a15a008) Bump system-lambda from 1.2.0 to 1.2.1 (#2490) + * [view commit](https://github.com/yahoo/elide/commit/d3bb5583a78fcbea62279cb2fb7c99b7da1ac9c6) Bump maven-scm-provider-gitexe from 1.12.0 to 1.12.2 (#2489) + * [view commit](https://github.com/yahoo/elide/commit/1fc1935a940bdcdb8d6b8d6bb17de95e8469b8d5) Bump maven-compiler-plugin from 3.8.1 to 3.9.0 (#2497) + * [view commit](https://github.com/yahoo/elide/commit/104dd73fdba0237a30939764f7267744227e573a) Bump dependency-check-maven from 6.5.2 to 6.5.3 (#2499) + * [view commit](https://github.com/yahoo/elide/commit/35cb0d02603445ca9997bcef5e8085486040d4b4) Bump maven-jar-plugin from 3.2.1 to 3.2.2 (#2498) + * [view commit](https://github.com/yahoo/elide/commit/5d9ce37e2f15a2703f530e83bf9a52eb0632ae6d) Bump calcite-core from 1.28.0 to 1.29.0 (#2483) + +## 6.0.5 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/af4fdc3e859f9737b992f231a857793c912d3039) bump log4j2 (#2460) + * [view commit](https://github.com/yahoo/elide/commit/16384c094b1974e293dc1f33d6330a6b0ac5821a) Use GraphqlBigDecimal for BigDecimal conversion (#2464) + * [view commit](https://github.com/yahoo/elide/commit/6b2b60098436a3dd7db03697ffd4717122fa3bf6) use query with filter to populate query parameters in pagination (#2466) + * [view commit](https://github.com/yahoo/elide/commit/ad03655829b88f6375b95d757be8abc0dbed4fd5) Bump spring-core from 5.3.13 to 5.3.14 (#2456) + * [view commit](https://github.com/yahoo/elide/commit/9a4849d738692723c45dda410587b3e8c1be897a) Bump h2 from 1.4.200 to 2.0.202 (#2427) + * [view commit](https://github.com/yahoo/elide/commit/865d5ac4c2006a8ac998082edbbd14844a375f8a) Bump log4j-api from 2.17.0 to 2.17.1 (#2468) + * [view commit](https://github.com/yahoo/elide/commit/e0b40a7bb8255075d411bf6131ecd2c72f06dbb3) Bump log4j-api from 2.17.0 to 2.17.1 in /elide-spring (#2467) + * [view commit](https://github.com/yahoo/elide/commit/a62df30e3a47af7c4657e8791e01c5403536d9d9) Bump metrics.version from 4.2.5 to 4.2.7 (#2455) + * [view commit](https://github.com/yahoo/elide/commit/cea09b700eec6f45d361ebe9d32ed19e2949cba0) Bump dependency-check-maven from 6.5.0 to 6.5.2 (#2463) + * [view commit](https://github.com/yahoo/elide/commit/6aac9bd0b2ceb2e06dfe1ee7b203fb186a4800db) Bump spring-websocket from 5.3.13 to 5.3.14 (#2461) + * [view commit](https://github.com/yahoo/elide/commit/307f29ba0ac68533f45b2c66f96a5fd0cc7edb8b) Bump mockito-core from 4.1.0 to 4.2.0 (#2458) + * [view commit](https://github.com/yahoo/elide/commit/7ca2d6d204550d2a0498d7ddd67b406620aae69b) jersey to 2.35 (#2472) + * [view commit](https://github.com/yahoo/elide/commit/d554658425300df2210df1e67110f0b7588633e0) Enable lifecycle, check, and other entity scans by default for Spring. (#2470) + * [view commit](https://github.com/yahoo/elide/commit/b931b4d5b2fb5a1ce4040762fc22f2df9f8d30eb) Disallow ConfigFile path to be changed in ConfigStore (#2471) + +## 6.0.4 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/5e9c1d7ff097ec3143de5ab7b20d5bafcf4ff0b0) Fixing #137 (#2433) + * [view commit](https://github.com/yahoo/elide/commit/de351f391f51d0d8a7586e9384c9f2657743a66f) Fixes #2263. JSON-API json processing errors will now return a 400 i… (#2434) + * [view commit](https://github.com/yahoo/elide/commit/ac520d61915aa77253fd69cee3c5713da85ede77) Bump caffeine from 3.0.4 to 3.0.5 (#2435) + * [view commit](https://github.com/yahoo/elide/commit/1cce9a9c4d9d29ab29638e7297000a604954eabb) Bump jetty-webapp from 9.4.43.v20210629 to 9.4.44.v20210927 (#2436) + * [view commit](https://github.com/yahoo/elide/commit/35ba78e82f7e230b5c8d3818ab67df7606e65f29) Fixes #2438 (#2441) + * [view commit](https://github.com/yahoo/elide/commit/a4f89601fb6acc03e6a3a0e0b29d8412e733eb77) Bump metrics.version from 4.2.4 to 4.2.5 (#2442) + * [view commit](https://github.com/yahoo/elide/commit/bd16677ae47419004707f5a7356aa952315c2746) Bump classgraph from 4.8.137 to 4.8.138 (#2445) + * [view commit](https://github.com/yahoo/elide/commit/469fe23e1bc9c8c6639c8df083472f437a69d1ae) Issue608 (#2446) + * [view commit](https://github.com/yahoo/elide/commit/8e1563b2e4abde911563b69a6b5a37ca6361d3a5) Bump micrometer-core from 1.8.0 to 1.8.1 (#2444) + * [view commit](https://github.com/yahoo/elide/commit/cc7b13da0aa6f276c034325be51793f6d6b866c0) Bump httpcore from 4.4.14 to 4.4.15 (#2443) + * [view commit](https://github.com/yahoo/elide/commit/9e2ff4727fbb11947d55536f59409b2e80766290) Resolves #2447 (#2448) + * [view commit](https://github.com/yahoo/elide/commit/2ad81220c9eecb5c009653d72b3204706200d03a) AsyncQueryOperation: fix index out of bounds error for empty list (#2449) + * [view commit](https://github.com/yahoo/elide/commit/0f2187a533022c3847f2bc744d4e1bd6f03e2595) Spaces in physical column name (#2450) + +## 6.0.3 + +**Features** + * [view commit](https://github.com/yahoo/elide/commit/54cdcb9fe67225cd463e1bf9357f0e1d2a42c152) Experimental HJSON Configuration Models and DataStore (#2418) + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/38b030daa701f205215b56199705f3d689726527) Fix issue with Enum types in Aggregation Store filters (#2422) + * [view commit](https://github.com/yahoo/elide/commit/21bf8717330d499fcb5baadadb6012c64f84062f) Bump mockito-core from 4.0.0 to 4.1.0 (#2421) + * [view commit](https://github.com/yahoo/elide/commit/e2fcd71b026d3d3a2c207dd48941e38de945f290) Bump spring-websocket from 5.3.12 to 5.3.13 (#2412) + * [view commit](https://github.com/yahoo/elide/commit/1444c00ca7fb2560d38358e01426dfd2eb20350b) Bump version.logback from 1.2.6 to 1.2.7 (#2415) + * [view commit](https://github.com/yahoo/elide/commit/88648be470fc881b1a7019becb141c7ffb78b143) Bump spring-core from 5.3.12 to 5.3.13 (#2416) + * [view commit](https://github.com/yahoo/elide/commit/aae18e32063a48d8b5fb7941becf3ddc6d814430) Bump version.junit from 5.8.1 to 5.8.2 (#2424) + * [view commit](https://github.com/yahoo/elide/commit/a4a796bc72a7b60f145828bee9a3cabc6b9926c1) Bump classgraph from 4.8.132 to 4.8.137 (#2425) + * [view commit](https://github.com/yahoo/elide/commit/a3da66f290a9e68f3e934eec890d479996f772b2) Bump checkstyle from 9.1 to 9.2 (#2426) + * [view commit](https://github.com/yahoo/elide/commit/63b4863f97f621b5b3ccd726f7129b61342566d3) Bump dependency-check-maven from 6.4.1 to 6.5.0 (#2413) + * [view commit](https://github.com/yahoo/elide/commit/d8050f843f8bb392eac26c454de63ab92264184a) Bump junit-platform-commons from 1.8.1 to 1.8.2 (#2429) + * [view commit](https://github.com/yahoo/elide/commit/d2a0749a4903060d8bc064701758d3cfa693da9e) Bump junit-platform-launcher from 1.8.1 to 1.8.2 (#2430) + * [view commit](https://github.com/yahoo/elide/commit/6a14913ac606860b713f332c91701bd66c553bb0) Bump micrometer-core from 1.7.5 to 1.8.0 (#2414) + * [view commit](https://github.com/yahoo/elide/commit/9f4fd01e2c66e9e5a48cc5d79061ad8afb6c9930) Bump spring.boot.version from 2.5.6 to 2.6.1 (#2423) + * [view commit](https://github.com/yahoo/elide/commit/decae2e5d7fb0ad44304af2b4a05c83ba26a809e) Bump mockito-junit-jupiter from 4.0.0 to 4.1.0 (#2428) + * [view commit](https://github.com/yahoo/elide/commit/c8b79396be2432782911c385ec73d0c623a03c72) Security Fix: #147 (#2431) + * [view commit](https://github.com/yahoo/elide/commit/6919c75bf8dd364267ff6e738166cc5d401d0412) Removing extra System.out from test. + +## 6.0.2 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/cc3b1f7edb491d5abfafaa2b2b1e0f3b7caa36bc) Aggregation Store: Arguments should require default values. (#2403) + * [view commit](https://github.com/yahoo/elide/commit/113e7f2e821fb83eeb2915e61c42b81ee659db67) Added IT test for Hibernate Type annotation. (#2404) + * [view commit](https://github.com/yahoo/elide/commit/b7712fc3d8860ab4c564d9cdea65334cfdc7f444) Bump classgraph from 4.8.129 to 4.8.130 (#2406) + * [view commit](https://github.com/yahoo/elide/commit/ab97e119b0c88bf37dac9be38c03cb923173da22) Bump version.antlr4 from 4.9.2 to 4.9.3 (#2405) + * [view commit](https://github.com/yahoo/elide/commit/c8266c116bb3dbd322cfefe86ea23c6320c6b9a8) Fixes async API resultset premature closure (#2410) + * [view commit](https://github.com/yahoo/elide/commit/5f2dc6c29ee678eaf1c89a18e5a4a9abc41bfe37) Bump classgraph from 4.8.130 to 4.8.132 (#2417) + +## 6.0.1 + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/85a20be1bd489054abcc5f4c7ed3314d3b0dd03a) Fixing bug where Hibernate is not detecting updates to complex attrib… (#2402) + * [view commit](https://github.com/yahoo/elide/commit/2e5b5efafc9262dbf815fd492eee5e89cf0151fa) Bump mockito-junit-jupiter from 3.12.4 to 4.0.0 (#2398) + +## 6.0.0 +**Overview** + +Official Elide 6 Release. Elide 6 is compiled with Java 11 (as opposed to 8). GraphQL subscription support has been added. Other changes since Elide 5 are summarized here [here](https://elide.io/pages/guide/v6/17-migration.html). + +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/3d6107a429a60bb273675eb55aec35c4387f5636) Lifecycle events should trigger once for relationship updates (#2389) + * [view commit](https://github.com/yahoo/elide/commit/7fcbc3ec80fb40bf267cd3a33074dce8e5d8e10b) Added a few additional tests around updating relationships (#2391) + * [view commit](https://github.com/yahoo/elide/commit/e2c15ca498e105b501d63c7d295deeb80c61ab73) Bump gson from 2.8.8 to 2.8.9 (#2394) + * [view commit](https://github.com/yahoo/elide/commit/3939bc4f1fdae70dd3cb8a92f962be544b81f39c) Bump classgraph from 4.8.128 to 4.8.129 (#2396) + * [view commit](https://github.com/yahoo/elide/commit/27d2e82bbcd79d197265b6e7dd976344f02e43ed) Bump checkstyle from 9.0.1 to 9.1 (#2395) + * [view commit](https://github.com/yahoo/elide/commit/5a63aad4d642d63fb8a398c31700b19eb4e3669f) Bump version.junit from 5.7.2 to 5.8.1 (#2326) + * [view commit](https://github.com/yahoo/elide/commit/868f08abb047012f0bc08e1693a7b7cef8a273f0) Bump mockito-junit-jupiter from 3.12.1 to 3.12.4 (#2282) + * [view commit](https://github.com/yahoo/elide/commit/4ce344f9bc00daddae70d77a6a5a6d1642de832b) Issue2376 (#2393) + +## 6.0.0-pr7 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/f652404f0a26657addc821e911a6c61f8159a500) Only check preflush hooks for GraphQL mutations (#2371) + * [view commit](https://github.com/yahoo/elide/commit/1bec9a04edb9917b34a9d168a3bec98f04fa8cad) Changing to Create Preflush (#2379) + * [view commit](https://github.com/yahoo/elide/commit/6a54e45f4e0ebd214003a169b7eaa7244051d366) Sort in memory for computed properties (#2380) + * [view commit](https://github.com/yahoo/elide/commit/5026c6bed03affed67f395a6bb9e4b6206dc62e5) Removing READ life cycle hooks. (#2381) + * [view commit](https://github.com/yahoo/elide/commit/844329422ed1edbcc563222b7e58c2ff861dd503) Adding test for #2376 (#2378) + * [view commit](https://github.com/yahoo/elide/commit/fa377c038f5f1bfd11bbd26d6a16a1691afd91e5) Bump spring-core from 5.3.9 to 5.3.12 (#2375) + * [view commit](https://github.com/yahoo/elide/commit/bcff59867ec1d1d36cd696fca34ad1df66088f59) Bump mockito-core from 3.12.4 to 4.0.0 (#2369) + * [view commit](https://github.com/yahoo/elide/commit/4598a96a3c67f3ba1ace62e9daa35aaf7dd0cb32) Bump maven-javadoc-plugin from 3.3.0 to 3.3.1 (#2298) + * [view commit](https://github.com/yahoo/elide/commit/b55cc49652543c18600a616174032d855d3a34a3) Bump hibernate5.version from 5.5.5.Final to 5.6.1.Final (#2384) + * [view commit](https://github.com/yahoo/elide/commit/37ca4cb76545968f32e75bba490452ac1e119e2e) Bump dependency-check-maven from 6.3.2 to 6.4.1 (#2367) + * [view commit](https://github.com/yahoo/elide/commit/63edada62c36bf004aa6920a31036b6b1cea116a) Bump spring-websocket from 5.3.11 to 5.3.12 (#2383) + * [view commit](https://github.com/yahoo/elide/commit/eac97d952e0cb291651c339d84555617edf0f4c0) Bump spring.boot.version from 2.5.5 to 2.5.6 (#2386) + * [view commit](https://github.com/yahoo/elide/commit/f87d43d0f891238dfe7c3b7fe677c3504eac5e3e) Bump jansi from 2.3.4 to 2.4.0 (#2385) + * [view commit](https://github.com/yahoo/elide/commit/1906a6ead98423b52741f7f849f8946b019e1063) set bypasscache true for Async Export (#2387) + * [view commit](https://github.com/yahoo/elide/commit/3815a731a36a49eb452cb787916ab09c07dd1c12) Bump commons-cli from 1.4 to 1.5.0 (#2388) + +## 6.0.0-pr6 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/b88c19ef352c7bff44d4375d4a5f3e6b55054c3e) Aggregation Store: Make EntityHydrator stream results. (#2333) + * [view commit](https://github.com/yahoo/elide/commit/6f26ba6377010a22403698d48d49ef2a4f897fde) Changing Oath to Yahoo in Copyright messages (#2340) + * [view commit](https://github.com/yahoo/elide/commit/5064f8a5a351b1f77b8025f68b20efc1b76d101e) Elide 6 : Refactor DataStoreTransaction Interface. (#2334) + * [view commit](https://github.com/yahoo/elide/commit/9a63da30302650b557d0b85059494ff8e8220b7b) Fixing toOne relationship bug for GraphQL subscriptions (#2335) + * [view commit](https://github.com/yahoo/elide/commit/0183fe7fe8c5933f82a4ffc3000b7ee638f5f14c) Subscription serialization (#2339) + * [view commit](https://github.com/yahoo/elide/commit/bc9729f22c40de5b864c52186ca2e130daba4637) Issue2337 (#2341) + * [view commit](https://github.com/yahoo/elide/commit/8455b9b8531b2ce031ac5912c96c2c07ce0360bf) Change GraphQL Preflush Hook Invocation (#2332) + * [view commit](https://github.com/yahoo/elide/commit/ca04a4a3c7da52734e31b134c5edbae7efd25a55) Revised JSON-API path grammar to accept colon, space, and ampersand i… (#2342) + * [view commit](https://github.com/yahoo/elide/commit/91d7022c76c673da76df1614617709b7c593d60a) Bump caffeine from 3.0.3 to 3.0.4 (#2327) + * [view commit](https://github.com/yahoo/elide/commit/cd5a7dcac2e525f2c1a2570efe686eb81c135a8d) Bump graphql-java from 17.2 to 17.3 (#2346) + * [view commit](https://github.com/yahoo/elide/commit/47c56d0c2ebbd5410a50fdd39a652b8eeb581138) Bump groovy.version from 3.0.8 to 3.0.9 (#2293) + * [view commit](https://github.com/yahoo/elide/commit/caca78e6bbbba3416cd6892d30c258bfabc0de54) Bump version.logback from 1.2.5 to 1.2.6 (#2324) + * [view commit](https://github.com/yahoo/elide/commit/5dfa639152cb75e689c4b9b52db8bf4953843b1a) Bump artemis-server from 2.18.0 to 2.19.0 (#2349) + * [view commit](https://github.com/yahoo/elide/commit/064d5a86fbbcc5ab927bd3645d5c85ea4139c59e) Bump artemis-jms-server from 2.18.0 to 2.19.0 (#2348) + * [view commit](https://github.com/yahoo/elide/commit/374ffd8ba987171a08d10a0f6655dc4a67918963) Bump spring.boot.version from 2.5.4 to 2.5.5 (#2350) + * [view commit](https://github.com/yahoo/elide/commit/1addd8824d337b690ec5e595a5c94b1fbd87d641) Bump swagger-core from 1.6.2 to 1.6.3 (#2351) + * [view commit](https://github.com/yahoo/elide/commit/6bc5f42fb18b04e926064418268e6c9335150ef1) Bump ant from 1.10.11 to 1.10.12 (#2352) + * [view commit](https://github.com/yahoo/elide/commit/e19227b84e5a372c4831dee7c52e6122c6ceec56) Bump artemis-jms-client-all from 2.18.0 to 2.19.0 (#2354) + * [view commit](https://github.com/yahoo/elide/commit/0e4ee93ab2f55fb91abc34cd55271f60ea613da5) Bump calcite-core from 1.27.0 to 1.28.0 (#2355) + * [view commit](https://github.com/yahoo/elide/commit/b5514565b12f35a6fcaa1216d2cba1b5afdad7a8) Bump metrics.version from 4.2.3 to 4.2.4 (#2356) + * [view commit](https://github.com/yahoo/elide/commit/cbf6efaa0795cf3484ee2dc9789c519a916dd99b) Bump maven-scm-provider-gitexe from 1.11.3 to 1.12.0 (#2353) + * [view commit](https://github.com/yahoo/elide/commit/0d6b0753567d86fca4f06d9bedfacf51816922ae) Bump checkstyle from 9.0 to 9.0.1 (#2358) + * [view commit](https://github.com/yahoo/elide/commit/196db51c342cc129ab37df9b7727217c9e3b8814) Bump handlebars-helpers from 4.2.0 to 4.3.0 (#2359) + * [view commit](https://github.com/yahoo/elide/commit/c4f422a2946de88e961be64b5f67821c5c41b137) Remove export pagination limit (#2362) + * [view commit](https://github.com/yahoo/elide/commit/9660cad7a1479d6ce9d7698e57cbf7b40e6ad92c) Bump classgraph from 4.8.116 to 4.8.128 (#2363) + * [view commit](https://github.com/yahoo/elide/commit/20b4961ecc51a2819a54e42e50746ed612a3dc5d) Bump spring-websocket from 5.3.10 to 5.3.11 (#2360) + * [view commit](https://github.com/yahoo/elide/commit/40481bee942e3a2ba2cfb90b6350f00da76ed190) Bump maven-scm-api from 1.11.3 to 1.12.0 (#2361) + * [view commit](https://github.com/yahoo/elide/commit/830b9f2a997eefcd677090875bede2e21e19f1f4) Support 'hidden' flag for analytic models and fields. (#2357) + * [view commit](https://github.com/yahoo/elide/commit/c3ea3c6a165df84449d717979466b85db5e0a575) Bump micrometer-core from 1.7.3 to 1.7.5 (#2366) + * [view commit](https://github.com/yahoo/elide/commit/db01f98dbec1f78d99d44880dc048a47ab74ba96) Bump lombok from 1.18.20 to 1.18.22 (#2364) + * [view commit](https://github.com/yahoo/elide/commit/cca1dcc92cc1ff37a026d5bd08fd5f747d5b59cb) Bump guava from 30.1.1-jre to 31.0.1-jre (#2365) + + +## 6.0.0-pr5 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/28c378c0c447923b67ab1674df50793480ed12df) Relaxing filter template matching to ignore aliases (#2322) + * [view commit](https://github.com/yahoo/elide/commit/a4e58460b4a2e9bd04ba4707ad31e62dd1dafe01) Values validation on filters that had operators like contains should not be enforced (#2323) + * [view commit](https://github.com/yahoo/elide/commit/3006b1e3aa672bb97c75d276ed9e3c469518976f) Bump junit-platform-launcher from 1.7.2 to 1.8.1 (#2313) + * [view commit](https://github.com/yahoo/elide/commit/5acec4ea96d5786e9b5118d606de40104a6cb851) Bump dependency-check-maven from 6.2.2 to 6.3.2 (#2318) + * [view commit](https://github.com/yahoo/elide/commit/69eb1e6d362bbd6f91d4ff102131f5a631506bce) No longer using attribute aliases to generate CSV export headers. In… (#2325) + * [view commit](https://github.com/yahoo/elide/commit/ae5afa1de35760c3603e743e63b8dbaad32b3951) Bump classgraph from 4.8.115 to 4.8.116 (#2296) + * [view commit](https://github.com/yahoo/elide/commit/23d8cde402d17137d40ce92fabb68d10517a3f2c) Fixing bug for complex attributes contain a Map of Object (#2328) + * [view commit](https://github.com/yahoo/elide/commit/64325a03c2d0e965e2543c3c52a9ddc0de570c03) Aggregation Store: Relaxing rules for how filter templates are compared against filter e… (#2329) + * [view commit](https://github.com/yahoo/elide/commit/9ad0d58b66ead60ede9a13e164fbb5a3c736d889) support case statement in Calcite Aggregation Extractor (#2330) + +## 6.0.0-pr4 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/0eb1624a401c9099c0e9f1f3fb539144cb3b97a1) Added new flag to enable subscription publishing in a separate service. Disallow queries in subscription endpoint. (#2320) + +## 6.0.0-pr3 +Prerelease candidate for Elide 6. + +### New Features in Elide 6.X + +Elide 6 introduces several new features: + - Elide 6 is built using Java 11 (as opposed to Java 8). + - GraphQL subscription support (experimental) is added along with a JMS data store that can read Elide models from JMS topics. + +### API Changes + +Prior to Elide 6, updates to complex, embedded attributes in Elide models required every field to be set in the attribute or they would be overwritten with nulls. Elide 6 is now aware of individual fields in complex, embedded attributes and only changes what has been sent by the client. See [#2277](https://github.com/yahoo/elide/issues/2277) for more details. + +### Interface Changes + + - EntityDictionary is now entirely constructed with a Builder. All prior constructors have been removed. + - Security checks are now instantiated at boot and reused across requests. This change requires security checks to be thread safe. + +### Module & Package Changes + +The following packages havea been removed: + + - Legacy datastore packages elide-hibernate-3 and elide-hibernate-5 have been retired and can be replaced with the JPA data store. + - The Elide bridgeable datastore has been removed. + - The package elide-datastore-hibernate has been renamed to elide-datastore-jpql. Internal package structure for the module was also changed. + +## 5.1.2 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/f54c0343fae51b8827da7bd719c460f39c3d56b1) Security Fix: #147 for elide-5.x (#2432) + +## 5.1.1 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/076a3171fa9c2240c16a2d3f2ced259d46202f7a) Fixes async API resultset premature closure. (#2411) + +## 5.1.0 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/2bd8e22fba35aeabc8f00b9836364b06a5abeea0) UPdating screwdriver build + * [view commit](https://github.com/yahoo/elide/commit/40d1413a725d61689892044e0ba6f21716cd5a84) Disabling OWASP checks for legacy build (Elide 5) + * [view commit](https://github.com/yahoo/elide/commit/cad5a714fd4eb3ce7dc6e95d8b1dba5a7ebbd379) (Similar to https://github.com/yahoo/elide/pull/2342) Revised JSON-API path grammar to accept colon, space, and ampersand in ID fields (#2343) + ## 5.0.12 **Features** * [view commit](https://github.com/yahoo/elide/commit/0b7eb0cb8b9fbb37fa412863a6d6fd1ac5734948) Add ability to retrieve data store properties in life cycle hooks. (#2278) @@ -496,6 +1017,12 @@ Because Elide 5 is a major release, we took time to reorganize the module & pack * New modules were created for elide-async (async API), elide-model-config (the semantic layer), and elide-datastore/elide-datastore-aggregation (the analytics module). * Some classes in elide-core were reorganized into new packages. +## 4.8.0 +**Fixes** + * [view commit](https://github.com/yahoo/elide/commit/233591723cd04a3b6da54c5234a824712ca613b4) (Similar to https://github.com/yahoo/elide/pull/2342) Revised JSON-API path grammar to accept colon, space, and ampersand in ID fields (#2344) + * [view commit](https://github.com/yahoo/elide/commit/f68bbce9a535e320929440b2408ea74b4c9edffc) Updating screwdriver build + * [view commit](https://github.com/yahoo/elide/commit/69b3edc7f935a39133ced3a97a9a496d58cfafcc) Disabling OWASP checks for legacy build (Elide 4) + ## 4.7.2 **Fixes** * [view commit](https://github.com/yahoo/elide/commit/eeab22ce7cc591c9747b40e509fea4a77e56c8af) Removed elide-example module diff --git a/elide-async/pom.xml b/elide-async/pom.xml index a809c25041..9a954dbe19 100644 --- a/elide-async/pom.xml +++ b/elide-async/pom.xml @@ -14,7 +14,7 @@ com.yahoo.elide elide-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -47,7 +47,7 @@ com.yahoo.elide elide-graphql - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -80,7 +80,19 @@ + + redis.clients + jedis + ${jedis.version} + + + + com.github.codemonstur + embedded-redis + ${embedded-redis.version} + test + javax.servlet @@ -90,7 +102,7 @@ org.glassfish.jersey.core jersey-server - 2.33 + ${version.jersey} test diff --git a/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/CSVExportFormatter.java b/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/CSVExportFormatter.java index ca189ed0c0..3c19433808 100644 --- a/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/CSVExportFormatter.java +++ b/elide-async/src/main/java/com/yahoo/elide/async/export/formatter/CSVExportFormatter.java @@ -8,10 +8,10 @@ import com.yahoo.elide.Elide; import com.yahoo.elide.async.models.TableExport; import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.request.EntityProjection; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.opendevl.JFlat; -import org.apache.commons.lang3.StringUtils; import lombok.extern.slf4j.Slf4j; import java.util.Arrays; @@ -84,20 +84,8 @@ private String generateCSVHeader(EntityProjection projection) { } return projection.getAttributes().stream() - .map(attr -> { - StringBuilder column = new StringBuilder(); - String alias = attr.getAlias(); - column.append(StringUtils.isNotEmpty(alias) ? alias : attr.getName()); - return column; - }) - .map(quotable -> { - // Quotes at the beginning - quotable.insert(0, DOUBLE_QUOTES); - // Quotes at the end - quotable.append(DOUBLE_QUOTES); - return quotable; - }) - .collect(Collectors.joining(COMMA)); + .map(this::toHeader) + .collect(Collectors.joining(COMMA)); } @Override @@ -113,4 +101,26 @@ public String preFormat(EntityProjection projection, TableExport query) { public String postFormat(EntityProjection projection, TableExport query) { return null; } + + private String toHeader(Attribute attribute) { + if (attribute.getArguments() == null || attribute.getArguments().size() == 0) { + return quote(attribute.getName()); + } + + StringBuilder header = new StringBuilder(); + header.append(attribute.getName()); + header.append("("); + + header.append(attribute.getArguments().stream() + .map((arg) -> arg.getName() + "=" + arg.getValue()) + .collect(Collectors.joining(" "))); + + header.append(")"); + + return quote(header.toString()); + } + + private String quote(String toQuote) { + return "\"" + toQuote + "\""; + } } diff --git a/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncAPIHook.java b/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncAPIHook.java index d28ad0e3d7..5b7334ffd0 100644 --- a/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncAPIHook.java +++ b/elide-async/src/main/java/com/yahoo/elide/async/hooks/AsyncAPIHook.java @@ -6,8 +6,8 @@ package com.yahoo.elide.async.hooks; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; -import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.POSTCOMMIT; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PREFLUSH; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRESECURITY; import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.async.models.AsyncAPI; @@ -58,16 +58,12 @@ protected void validateOptions(AsyncAPI query, RequestScope requestScope) { */ protected void executeHook(LifeCycleHookBinding.Operation operation, LifeCycleHookBinding.TransactionPhase phase, AsyncAPI query, RequestScope requestScope, Callable queryWorker) { - if (operation.equals(READ) && phase.equals(PRESECURITY)) { - validateOptions(query, requestScope); - //We populate the result object when the initial mutation is executed, and then even after executing - //the hooks we return the same object back. QueryRunner.java#L190. - //In GraphQL, the only part of the body that is lazily returned is the ID. - //ReadPreSecurityHook - Those hooks get evaluated in line with the request processing. - executeAsync(query, queryWorker); - return; - } if (operation.equals(CREATE)) { + if (phase.equals(PREFLUSH)) { + validateOptions(query, requestScope); + executeAsync(query, queryWorker); + return; + } if (phase.equals(POSTCOMMIT)) { completeAsync(query, requestScope); return; diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/FileExtensionType.java b/elide-async/src/main/java/com/yahoo/elide/async/models/FileExtensionType.java new file mode 100644 index 0000000000..409d2e7fa6 --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/FileExtensionType.java @@ -0,0 +1,26 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.async.models; + +/** + * ENUM of supported file extension types. + */ +public enum FileExtensionType { + JSON(".json"), + CSV(".csv"), + NONE(""); + + private final String extension; + + FileExtensionType(String extension) { + this.extension = extension; + } + + public String getExtension() { + return extension; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/models/ResultType.java b/elide-async/src/main/java/com/yahoo/elide/async/models/ResultType.java index edaff10fb0..35e7e79904 100644 --- a/elide-async/src/main/java/com/yahoo/elide/async/models/ResultType.java +++ b/elide-async/src/main/java/com/yahoo/elide/async/models/ResultType.java @@ -9,6 +9,16 @@ * ENUM of supported result types. */ public enum ResultType { - JSON, - CSV + JSON(FileExtensionType.JSON), + CSV(FileExtensionType.CSV); + + private final FileExtensionType fileExtensionType; + + ResultType(FileExtensionType fileExtensionType) { + this.fileExtensionType = fileExtensionType; + } + + public FileExtensionType getFileExtensionType() { + return fileExtensionType; + } } diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncQueryOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncQueryOperation.java index 053e500077..14289aafff 100644 --- a/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncQueryOperation.java +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/AsyncQueryOperation.java @@ -97,7 +97,9 @@ public static Integer safeJsonPathLength(String json, String path) { } if (List.class.isAssignableFrom(result.getClass())) { - result = ((List) result).get(0); + List resultList = ((List) result); + + result = resultList.isEmpty() ? 0 : resultList.get(0); if (Integer.class.isAssignableFrom(result.getClass())) { return (Integer) result; } diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLTableExportOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLTableExportOperation.java index 647698c0f3..5045f9cc50 100644 --- a/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLTableExportOperation.java +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/GraphQLTableExportOperation.java @@ -27,7 +27,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -44,12 +44,13 @@ public GraphQLTableExportOperation(TableExportFormatter formatter, AsyncExecutor } @Override - public RequestScope getRequestScope(TableExport export, RequestScope scope, DataStoreTransaction tx) { + public RequestScope getRequestScope(TableExport export, RequestScope scope, DataStoreTransaction tx, + Map> additionalRequestHeaders) { UUID requestId = UUID.fromString(export.getRequestId()); User user = scope.getUser(); String apiVersion = scope.getApiVersion(); return new GraphQLRequestScope("", tx, user, apiVersion, getService().getElide().getElideSettings(), - null, requestId, Collections.emptyMap()); + null, requestId, additionalRequestHeaders); } @Override diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPITableExportOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPITableExportOperation.java index c677961cf2..b8e20f94c9 100644 --- a/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPITableExportOperation.java +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/JSONAPITableExportOperation.java @@ -25,6 +25,9 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.UUID; import javax.ws.rs.core.MultivaluedMap; @@ -41,7 +44,8 @@ public JSONAPITableExportOperation(TableExportFormatter formatter, AsyncExecutor } @Override - public RequestScope getRequestScope(TableExport export, RequestScope scope, DataStoreTransaction tx) { + public RequestScope getRequestScope(TableExport export, RequestScope scope, DataStoreTransaction tx, + Map> additionalRequestHeaders) { UUID requestId = UUID.fromString(export.getRequestId()); User user = scope.getUser(); String apiVersion = scope.getApiVersion(); @@ -51,7 +55,22 @@ public RequestScope getRequestScope(TableExport export, RequestScope scope, Data } catch (URISyntaxException e) { throw new BadRequestException(e.getMessage()); } + MultivaluedMap queryParams = JSONAPIAsyncQueryOperation.getQueryParams(uri); + + // Call with additionalHeader alone + if (scope.getRequestHeaders().isEmpty()) { + return new RequestScope("", JSONAPIAsyncQueryOperation.getPath(uri), apiVersion, null, tx, user, + queryParams, additionalRequestHeaders, requestId, getService().getElide().getElideSettings()); + } + + // Combine additionalRequestHeaders and existing scope's request headers + Map> finalRequestHeaders = new HashMap>(); + scope.getRequestHeaders().forEach((entry, value) -> finalRequestHeaders.put(entry, value)); + + //additionalRequestHeaders will override any headers in scope.getRequestHeaders() + additionalRequestHeaders.forEach((entry, value) -> finalRequestHeaders.put(entry, value)); + return new RequestScope("", JSONAPIAsyncQueryOperation.getPath(uri), apiVersion, null, tx, user, queryParams, scope.getRequestHeaders(), requestId, getService().getElide().getElideSettings()); } diff --git a/elide-async/src/main/java/com/yahoo/elide/async/operation/TableExportOperation.java b/elide-async/src/main/java/com/yahoo/elide/async/operation/TableExportOperation.java index 786d4ce86e..68ea59e7a3 100644 --- a/elide-async/src/main/java/com/yahoo/elide/async/operation/TableExportOperation.java +++ b/elide-async/src/main/java/com/yahoo/elide/async/operation/TableExportOperation.java @@ -11,6 +11,7 @@ import com.yahoo.elide.async.export.validator.Validator; import com.yahoo.elide.async.models.AsyncAPI; import com.yahoo.elide.async.models.AsyncAPIResult; +import com.yahoo.elide.async.models.FileExtensionType; import com.yahoo.elide.async.models.TableExport; import com.yahoo.elide.async.models.TableExportResult; import com.yahoo.elide.async.service.AsyncExecutorService; @@ -19,7 +20,6 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.exceptions.BadRequestException; -import com.yahoo.elide.core.exceptions.TransactionException; import com.yahoo.elide.core.request.EntityProjection; import io.reactivex.Observable; import lombok.Getter; @@ -33,7 +33,9 @@ import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.Callable; @@ -62,18 +64,32 @@ public TableExportOperation(TableExportFormatter formatter, AsyncExecutorService @Override public AsyncAPIResult call() { - String apiVersion = scope.getApiVersion(); log.debug("TableExport Object from request: {}", exportObj); Elide elide = service.getElide(); TableExportResult exportResult = new TableExportResult(); + UUID requestId = UUID.fromString(exportObj.getRequestId()); try (DataStoreTransaction tx = elide.getDataStore().beginTransaction()) { + // Do Not Cache Export Results + Map> requestHeaders = new HashMap>(); + requestHeaders.put("bypasscache", new ArrayList(Arrays.asList("true"))); - RequestScope requestScope = getRequestScope(exportObj, scope, tx); + RequestScope requestScope = getRequestScope(exportObj, scope, tx, requestHeaders); Collection projections = getProjections(exportObj, requestScope); validateProjections(projections); EntityProjection projection = projections.iterator().next(); - Observable observableResults = export(exportObj, requestScope, projection); + Observable observableResults = Observable.empty(); + + elide.getTransactionRegistry().addRunningTransaction(requestId, tx); + + //TODO - we need to add the baseUrlEndpoint to the queryObject. + //TODO - Can we have projectionInfo as null? + requestScope.setEntityProjection(projection); + + if (projection != null) { + projection.setPagination(null); + observableResults = PersistentResource.loadRecords(projection, Collections.emptyList(), requestScope); + } Observable results = Observable.empty(); String preResult = formatter.preFormat(projection, exportObj); @@ -87,20 +103,33 @@ public AsyncAPIResult call() { Observable interimResults = concatStringWithObservable(preResult, results, true); Observable finalResults = concatStringWithObservable(postResult, interimResults, false); - storeResults(exportObj, engine, finalResults); + TableExportResult result = storeResults(exportObj, engine, finalResults); + + if (result != null && result.getMessage() != null) { + throw new IllegalStateException(result.getMessage()); + } exportResult.setUrl(new URL(generateDownloadURL(exportObj, scope))); exportResult.setRecordCount(recordNumber); + + tx.flush(requestScope); + elide.getAuditLogger().commit(); + tx.commit(requestScope); } catch (BadRequestException e) { exportResult.setMessage(e.getMessage()); } catch (MalformedURLException e) { exportResult.setMessage("Download url generation failure."); - } catch (Exception e) { + } catch (IOException e) { + log.error("IOException during TableExport", e); + exportResult.setMessage(e.getMessage()); + } catch (Exception e) { exportResult.setMessage(e.getMessage()); } finally { // Follows same flow as GraphQL. The query may result in failure but request was successfully processed. exportResult.setHttpStatus(200); exportResult.setCompletedOn(new Date()); + elide.getTransactionRegistry().removeRunningTransaction(requestId); + elide.getAuditLogger().clear(); } return exportResult; } @@ -115,64 +144,16 @@ private Observable concatStringWithObservable(String toConcat, Observabl : observable.concatWith(Observable.just(toConcat)); } - /** - * Export Table Data. - * @param exportObj TableExport type object. - * @param prevScope RequestScope object. - * @param projection Entity projection. - * @return Observable PersistentResource - */ - private Observable export(TableExport exportObj, RequestScope scope, - EntityProjection projection) { - Observable results = Observable.empty(); - Elide elide = service.getElide(); - - UUID requestId = UUID.fromString(exportObj.getRequestId()); - - try { - DataStoreTransaction tx = scope.getTransaction(); - elide.getTransactionRegistry().addRunningTransaction(requestId, tx); - - //TODO - we need to add the baseUrlEndpoint to the queryObject. - //TODO - Can we have projectionInfo as null? - RequestScope exportRequestScope = getRequestScope(exportObj, scope, tx); - exportRequestScope.setEntityProjection(projection); - - if (projection != null) { - results = PersistentResource.loadRecords(projection, Collections.emptyList(), exportRequestScope); - } - - tx.preCommit(exportRequestScope); - exportRequestScope.runQueuedPreSecurityTriggers(); - exportRequestScope.getPermissionExecutor().executeCommitChecks(); - - tx.flush(exportRequestScope); - - exportRequestScope.runQueuedPreCommitTriggers(); - - elide.getAuditLogger().commit(); - tx.commit(exportRequestScope); - - exportRequestScope.runQueuedPostCommitTriggers(); - } catch (IOException e) { - log.error("IOException during TableExport", e); - throw new TransactionException(e); - } finally { - elide.getTransactionRegistry().removeRunningTransaction(requestId); - elide.getAuditLogger().clear(); - } - - return results; - } - /** * Initializes a new RequestScope for the export operation with the submitted query. * @param exportObj TableExport type object. * @param scope RequestScope from the original submission. * @param tx DataStoreTransaction. + * @param additionalRequestHeaders Additional Request Headers. * @return RequestScope Type Object */ - public abstract RequestScope getRequestScope(TableExport exportObj, RequestScope scope, DataStoreTransaction tx); + public abstract RequestScope getRequestScope(TableExport exportObj, RequestScope scope, DataStoreTransaction tx, + Map> additionalRequestHeaders); /** * Generate Download URL. @@ -183,7 +164,10 @@ private Observable export(TableExport exportObj, RequestScop public String generateDownloadURL(TableExport exportObj, RequestScope scope) { String downloadPath = scope.getElideSettings().getExportApiPath(); String baseURL = scope.getBaseUrlEndPoint(); - return baseURL + downloadPath + "/" + exportObj.getId(); + String extension = this.engine.isExtensionEnabled() + ? exportObj.getResultType().getFileExtensionType().getExtension() + : FileExtensionType.NONE.getExtension(); + return baseURL + downloadPath + "/" + exportObj.getId() + extension; } /** @@ -191,9 +175,9 @@ public String generateDownloadURL(TableExport exportObj, RequestScope scope) { * @param exportObj TableExport type object. * @param resultStorageEngine ResultStorageEngine instance. * @param result Observable of String Results to store. - * @return TableExport object. + * @return TableExportResult object. */ - protected TableExport storeResults(TableExport exportObj, ResultStorageEngine resultStorageEngine, + protected TableExportResult storeResults(TableExport exportObj, ResultStorageEngine resultStorageEngine, Observable result) { return resultStorageEngine.storeResults(exportObj, result); } diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngine.java b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngine.java index 72c03c2366..16dc2bbc34 100644 --- a/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngine.java +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngine.java @@ -6,7 +6,10 @@ package com.yahoo.elide.async.service.storageengine; +import com.yahoo.elide.async.models.FileExtensionType; import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.models.TableExportResult; + import io.reactivex.Observable; import lombok.Getter; import lombok.Setter; @@ -23,27 +26,34 @@ /** * Default implementation of ResultStorageEngine that stores results on local filesystem. - * It supports Async Module to store results with async query. + * It supports Async Module to store results with Table Export query. */ @Singleton @Slf4j @Getter public class FileResultStorageEngine implements ResultStorageEngine { @Setter private String basePath; + @Setter private boolean enableExtension; /** * Constructor. * @param basePath basePath for storing the files. Can be absolute or relative. + * @param enableExtension Enable file extensions. */ - public FileResultStorageEngine(String basePath) { + public FileResultStorageEngine(String basePath, boolean enableExtension) { this.basePath = basePath; + this.enableExtension = enableExtension; } @Override - public TableExport storeResults(TableExport tableExport, Observable result) { - log.debug("store AsyncResults for Download"); + public TableExportResult storeResults(TableExport tableExport, Observable result) { + log.debug("store TableExportResults for Download"); + String extension = this.isExtensionEnabled() + ? tableExport.getResultType().getFileExtensionType().getExtension() + : FileExtensionType.NONE.getExtension(); - try (BufferedWriter writer = getWriter(tableExport.getId())) { + TableExportResult exportResult = new TableExportResult(); + try (BufferedWriter writer = getWriter(tableExport.getId(), extension)) { result .map(record -> record.concat(System.lineSeparator())) .subscribe( @@ -52,6 +62,11 @@ public TableExport storeResults(TableExport tableExport, Observable resu writer.flush(); }, throwable -> { + StringBuilder message = new StringBuilder(); + message.append(throwable.getClass().getCanonicalName()).append(" : "); + message.append(throwable.getMessage()); + exportResult.setMessage(message.toString()); + throw new IllegalStateException(STORE_ERROR, throwable); }, writer::flush @@ -60,15 +75,15 @@ public TableExport storeResults(TableExport tableExport, Observable resu throw new IllegalStateException(STORE_ERROR, e); } - return tableExport; + return exportResult; } @Override - public Observable getResultsByID(String asyncQueryID) { - log.debug("getAsyncResultsByID"); + public Observable getResultsByID(String tableExportID) { + log.debug("getTableExportResultsByID"); return Observable.using( - () -> getReader(asyncQueryID), + () -> getReader(tableExportID), reader -> Observable.fromIterable(() -> new Iterator() { private String record = null; @@ -93,21 +108,26 @@ public String next() { BufferedReader::close); } - private BufferedReader getReader(String asyncQueryID) { + private BufferedReader getReader(String tableExportID) { try { - return Files.newBufferedReader(Paths.get(basePath + File.separator + asyncQueryID)); + return Files.newBufferedReader(Paths.get(basePath + File.separator + tableExportID)); } catch (IOException e) { log.debug(e.getMessage()); throw new IllegalStateException(RETRIEVE_ERROR, e); } } - private BufferedWriter getWriter(String asyncQueryID) { + private BufferedWriter getWriter(String tableExportID, String extension) { try { - return Files.newBufferedWriter(Paths.get(basePath + File.separator + asyncQueryID)); + return Files.newBufferedWriter(Paths.get(basePath + File.separator + tableExportID + extension)); } catch (IOException e) { log.debug(e.getMessage()); throw new IllegalStateException(STORE_ERROR, e); } } + + @Override + public boolean isExtensionEnabled() { + return this.enableExtension; + } } diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngine.java b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngine.java new file mode 100644 index 0000000000..9ffa242c7e --- /dev/null +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngine.java @@ -0,0 +1,127 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.async.service.storageengine; + +import com.yahoo.elide.async.models.FileExtensionType; +import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.models.TableExportResult; + +import io.reactivex.Observable; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import redis.clients.jedis.UnifiedJedis; + +import java.util.Iterator; + +import javax.inject.Singleton; + +/** + * Implementation of ResultStorageEngine that stores results on Redis Cluster. + * It supports Async Module to store results with Table Export query. + */ +@Singleton +@Slf4j +@Getter +public class RedisResultStorageEngine implements ResultStorageEngine { + @Setter private UnifiedJedis jedis; + @Setter private boolean enableExtension; + @Setter private long expirationSeconds; + @Setter private long batchSize; + + /** + * Constructor. + * @param jedis Jedis Connection Pool to Redis clusteer. + * @param enableExtension Enable file extensions. + * @param expirationSeconds Expiration Time for results on Redis. + * @param batchSize Batch Size for retrieving from Redis. + */ + public RedisResultStorageEngine(UnifiedJedis jedis, boolean enableExtension, long expirationSeconds, + long batchSize) { + this.jedis = jedis; + this.enableExtension = enableExtension; + this.expirationSeconds = expirationSeconds; + this.batchSize = batchSize; + } + + @Override + public TableExportResult storeResults(TableExport tableExport, Observable result) { + log.debug("store TableExportResults for Download"); + String extension = this.isExtensionEnabled() + ? tableExport.getResultType().getFileExtensionType().getExtension() + : FileExtensionType.NONE.getExtension(); + + TableExportResult exportResult = new TableExportResult(); + String key = tableExport.getId() + extension; + + result + .map(record -> record) + .subscribe( + recordCharArray -> { + jedis.rpush(key, recordCharArray); + }, + throwable -> { + StringBuilder message = new StringBuilder(); + message.append(throwable.getClass().getCanonicalName()).append(" : "); + message.append(throwable.getMessage()); + exportResult.setMessage(message.toString()); + + throw new IllegalStateException(STORE_ERROR, throwable); + } + ); + jedis.expire(key, expirationSeconds); + + return exportResult; + } + + @Override + public Observable getResultsByID(String tableExportID) { + log.debug("getTableExportResultsByID"); + + long recordCount = jedis.llen(tableExportID); + + if (recordCount == 0) { + throw new IllegalStateException(RETRIEVE_ERROR); + } else { + // Workaround for Local variable defined in an enclosing scope must be final or effectively final; + // use Array. + long[] recordRead = {0}; // index to start. + return Observable.fromIterable(() -> new Iterator() { + @Override + public boolean hasNext() { + return recordRead[0] < recordCount; + } + @Override + public String next() { + StringBuilder record = new StringBuilder(); + long end = recordRead[0] + batchSize - 1; // index of last element. + + if (end >= recordCount) { + end = recordCount - 1; + } + + Iterator itr = jedis.lrange(tableExportID, recordRead[0], end).iterator(); + + // Combine the list into a single string. + while (itr.hasNext()) { + String str = itr.next(); + record.append(str).append(System.lineSeparator()); + } + recordRead[0] = end + 1; //index for next element to be read + + // Removing the last line separator because ExportEndPoint will add 1 more. + return record.substring(0, record.length() - System.lineSeparator().length()); + } + }); + } + } + + @Override + public boolean isExtensionEnabled() { + return this.enableExtension; + } +} diff --git a/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/ResultStorageEngine.java b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/ResultStorageEngine.java index df2595ce28..8e1eccd502 100644 --- a/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/ResultStorageEngine.java +++ b/elide-async/src/main/java/com/yahoo/elide/async/service/storageengine/ResultStorageEngine.java @@ -7,6 +7,8 @@ package com.yahoo.elide.async.service.storageengine; import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.async.models.TableExportResult; + import io.reactivex.Observable; /** @@ -20,14 +22,20 @@ public interface ResultStorageEngine { * Stores the result of the query. * @param tableExport TableExport object * @param result is the observable result obtained by running the query - * @return String to store as attachment. Can be null. + * @return TableExportResult. */ - public TableExport storeResults(TableExport tableExport, Observable result); + public TableExportResult storeResults(TableExport tableExport, Observable result); /** * Searches for the async query results by ID and returns the record. - * @param asyncQueryID is the query ID of the AsyncQuery - * @return returns the result associated with the AsyncQueryID + * @param tableExportID is the ID of the TableExport. It may include extension too if enabled. + * @return returns the result associated with the tableExportID + */ + public Observable getResultsByID(String tableExportID); + + /** + * Whether the result storage engine has enabled extensions for attachments. + * @return returns whether the file extensions are enabled */ - public Observable getResultsByID(String asyncQueryID); + public boolean isExtensionEnabled(); } diff --git a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java index 04e96ca1f3..da68751433 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/CSVExportFormatterTest.java @@ -20,6 +20,7 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.request.Argument; import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.security.checks.Check; @@ -54,6 +55,7 @@ public void setupMocks(@TempDir Path tempDir) { .withEntityDictionary(EntityDictionary.builder().checks(map).build()) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) .build()); + elide.doScans(); scope = mock(RequestScope.class); } @@ -167,6 +169,60 @@ public void testHeader() { assertEquals("\"query\",\"queryType\"", output); } + @Test + public void testHeaderWithNonmatchingAlias() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + + TableExport queryObj = new TableExport(); + String query = "{ tableExport { edges { node { query queryType } } } }"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + attributes.add(Attribute.builder().type(TableExport.class).name("query").alias("foo").build()); + attributes.add(Attribute.builder().type(TableExport.class).name("queryType").build()); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + String output = formatter.preFormat(projection, queryObj); + assertEquals("\"query\",\"queryType\"", output); + } + + @Test + public void testHeaderWithArguments() { + CSVExportFormatter formatter = new CSVExportFormatter(elide, false); + + TableExport queryObj = new TableExport(); + String query = "{ tableExport { edges { node { query queryType } } } }"; + String id = "edc4a871-dff2-4054-804e-d80075cf827d"; + queryObj.setId(id); + queryObj.setQuery(query); + queryObj.setQueryType(QueryType.GRAPHQL_V1_0); + queryObj.setResultType(ResultType.CSV); + + // Prepare EntityProjection + Set attributes = new LinkedHashSet<>(); + attributes.add(Attribute.builder() + .type(TableExport.class) + .name("query") + .argument(Argument.builder().name("foo").value("bar").build()) + .alias("query").build()); + + attributes.add(Attribute.builder() + .type(TableExport.class) + .argument(Argument.builder().name("foo").value("bar").build()) + .argument(Argument.builder().name("baz").value("boo").build()) + .name("queryType") + .build()); + EntityProjection projection = EntityProjection.builder().type(TableExport.class).attributes(attributes).build(); + + String output = formatter.preFormat(projection, queryObj); + assertEquals("\"query(foo=bar)\",\"queryType(foo=bar baz=boo)\"", output); + } + @Test public void testHeaderSkip() { CSVExportFormatter formatter = new CSVExportFormatter(elide, true); diff --git a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java index d109b24dc4..a329fc6d8e 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/export/formatter/JSONExportFormatterTest.java @@ -53,6 +53,7 @@ public void setupMocks(@TempDir Path tempDir) { .withEntityDictionary(EntityDictionary.builder().checks(map).build()) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) .build()); + elide.doScans(); scope = mock(RequestScope.class); } diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperationTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperationTest.java index ab5cc464a7..35c21d5de3 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperationTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLAsyncQueryOperationTest.java @@ -90,7 +90,7 @@ public void testProcessQueryGraphQlInvalidResponse() throws URISyntaxException { } @Test - public void testProcessQueryGraphQlRunnerException() throws URISyntaxException { + public void testProcessQueryGraphQlRunnerException() { AsyncQuery queryObj = new AsyncQuery(); String query = "{\"query\":\"{ group { edges { node { name commonName description } } } }\",\"variables\":null}"; String id = "edc4a871-dff2-4054-804e-d80075cf827d"; @@ -104,7 +104,7 @@ public void testProcessQueryGraphQlRunnerException() throws URISyntaxException { } @Test - public void testProcessQueryGraphQlApiVersionNotSupported() throws URISyntaxException { + public void testProcessQueryGraphQlApiVersionNotSupported() { AsyncQuery queryObj = new AsyncQuery(); String query = "{\"query\":\"{ group { edges { node { name commonName description } } } }\",\"variables\":null}"; String id = "edc4a871-dff2-4054-804e-d80075cf827d"; diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLTableExportOperationTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLTableExportOperationTest.java index f515770cc3..6719632b06 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLTableExportOperationTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/GraphQLTableExportOperationTest.java @@ -36,7 +36,6 @@ import org.junit.jupiter.api.io.TempDir; import java.io.IOException; -import java.net.URISyntaxException; import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; @@ -73,10 +72,11 @@ public void setupMocks(@TempDir Path tempDir) { .withAuditLogger(new Slf4jLogger()) .withExportApiPath("/export") .build()); + elide.doScans(); user = mock(User.class); requestScope = mock(RequestScope.class); asyncExecutorService = mock(AsyncExecutorService.class); - engine = new FileResultStorageEngine(tempDir.toString()); + engine = new FileResultStorageEngine(tempDir.toString(), false); when(asyncExecutorService.getElide()).thenReturn(elide); when(requestScope.getApiVersion()).thenReturn(NO_VERSION); when(requestScope.getUser()).thenReturn(user); @@ -85,7 +85,7 @@ public void setupMocks(@TempDir Path tempDir) { } @Test - public void testProcessQuery() throws URISyntaxException, IOException { + public void testProcessQuery() throws IOException { dataPrep(); TableExport queryObj = new TableExport(); String query = "{\"query\":\"{ tableExport { edges { node { id principalName} } } }\",\"variables\":null}"; @@ -105,7 +105,7 @@ public void testProcessQuery() throws URISyntaxException, IOException { } @Test - public void testProcessBadEntityQuery() throws URISyntaxException, IOException { + public void testProcessBadEntityQuery() throws IOException { dataPrep(); TableExport queryObj = new TableExport(); String query = "{\"query\":\"{ tableExportInvalid { edges { node { id principalName} } } }\",\"variables\":null}"; @@ -124,7 +124,7 @@ public void testProcessBadEntityQuery() throws URISyntaxException, IOException } @Test - public void testProcessBadQuery() throws URISyntaxException, IOException { + public void testProcessBadQuery() throws IOException { dataPrep(); TableExport queryObj = new TableExport(); String query = "{\"query\":\"{ tableExport { edges { node { id principalName} } }\",\"variables\":null}"; diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/JsonAPITableExportOperationTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/JsonAPITableExportOperationTest.java index 0fcb217f49..037ad5ca3c 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/operation/JsonAPITableExportOperationTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/JsonAPITableExportOperationTest.java @@ -36,7 +36,6 @@ import org.junit.jupiter.api.io.TempDir; import java.io.IOException; -import java.net.URISyntaxException; import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; @@ -74,10 +73,11 @@ public void setupMocks(@TempDir Path tempDir) { .withAuditLogger(new Slf4jLogger()) .withExportApiPath("/export") .build()); + elide.doScans(); user = mock(User.class); requestScope = mock(RequestScope.class); asyncExecutorService = mock(AsyncExecutorService.class); - engine = new FileResultStorageEngine(tempDir.toString()); + engine = new FileResultStorageEngine(tempDir.toString(), true); when(asyncExecutorService.getElide()).thenReturn(elide); when(requestScope.getApiVersion()).thenReturn(NO_VERSION); when(requestScope.getUser()).thenReturn(user); @@ -86,7 +86,7 @@ public void setupMocks(@TempDir Path tempDir) { } @Test - public void testProcessQuery() throws URISyntaxException, IOException { + public void testProcessQuery() throws IOException { dataPrep(); TableExport queryObj = new TableExport(); String query = "/tableExport?sort=principalName&fields=principalName"; @@ -101,13 +101,13 @@ public void testProcessQuery() throws URISyntaxException, IOException { TableExportResult queryResultObj = (TableExportResult) jsonAPIOperation.call(); assertEquals(200, queryResultObj.getHttpStatus()); - assertEquals("https://elide.io/export/edc4a871-dff2-4054-804e-d80075cf827d", queryResultObj.getUrl().toString()); + assertEquals("https://elide.io/export/edc4a871-dff2-4054-804e-d80075cf827d.csv", queryResultObj.getUrl().toString()); assertEquals(1, queryResultObj.getRecordCount()); assertNull(queryResultObj.getMessage()); } @Test - public void testProcessBadEntityQuery() throws URISyntaxException, IOException { + public void testProcessBadEntityQuery() throws IOException { dataPrep(); TableExport queryObj = new TableExport(); String query = "/tableExportInvalid?sort=principalName&fields=principalName"; @@ -126,7 +126,7 @@ public void testProcessBadEntityQuery() throws URISyntaxException, IOException } @Test - public void testProcessBadQuery() throws URISyntaxException, IOException { + public void testProcessBadQuery() throws IOException { dataPrep(); TableExport queryObj = new TableExport(); String query = "tableExport/^IllegalCharacter^"; diff --git a/elide-async/src/test/java/com/yahoo/elide/async/operation/SafeJsonPathTest.java b/elide-async/src/test/java/com/yahoo/elide/async/operation/SafeJsonPathTest.java index b21b57fc04..d837c79997 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/operation/SafeJsonPathTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/operation/SafeJsonPathTest.java @@ -28,6 +28,13 @@ public void testArrayResult() { assertEquals(10, count); } + @Test + public void testEmptyArrayResult() { + Integer count = AsyncQueryOperation.safeJsonPathLength("{ \"data\": [] }", "data"); + + assertEquals(0, count); + } + @Test public void testInvalidREsult() { assertThrows(IllegalStateException.class, () -> diff --git a/elide-async/src/test/java/com/yahoo/elide/async/resources/ExportApiEndpointTest.java b/elide-async/src/test/java/com/yahoo/elide/async/resources/ExportApiEndpointTest.java index 390216254a..50bfec9762 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/resources/ExportApiEndpointTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/resources/ExportApiEndpointTest.java @@ -19,7 +19,6 @@ import org.mockito.ArgumentCaptor; import io.reactivex.Observable; -import java.io.IOException; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import javax.servlet.http.HttpServletResponse; @@ -47,7 +46,7 @@ public void setup() { } @Test - public void testGet() throws InterruptedException, IOException { + public void testGet() { String queryId = "1"; int maxDownloadTimeSeconds = 1; int maxDownloadTimeMilliSeconds = (int) TimeUnit.SECONDS.toMillis(maxDownloadTimeSeconds); diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncExecutorServiceTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncExecutorServiceTest.java index 9eb89c36a5..4168677083 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncExecutorServiceTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/AsyncExecutorServiceTest.java @@ -95,7 +95,7 @@ public void testExecuteQueryFail() throws Exception { //Test for executor hook execution @Test - public void testExecuteQueryComplete() throws InterruptedException { + public void testExecuteQueryComplete() { AsyncQuery queryObj = mock(AsyncQuery.class); String query = "/group?sort=commonName&fields%5Bgroup%5D=commonName,description"; diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAOTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAOTest.java index cba47e7976..caa983ef2a 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAOTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/dao/DefaultAsyncAPIDAOTest.java @@ -19,6 +19,7 @@ import com.yahoo.elide.async.models.QueryStatus; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; @@ -58,8 +59,8 @@ public void setupMocks() { ElideSettings elideSettings = new ElideSettingsBuilder(dataStore) .withEntityDictionary(dictionary) - .withJoinFilterDialect(new RSQLFilterDialect(dictionary)) - .withSubqueryFilterDialect(new RSQLFilterDialect(dictionary)) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) .build(); @@ -86,7 +87,7 @@ public void testUpdateStatus() { @Test public void testUpdateStatusAsyncQueryCollection() { Iterable loaded = Arrays.asList(asyncQuery, asyncQuery); - when(tx.loadObjects(any(), any())).thenReturn(loaded); + when(tx.loadObjects(any(), any())).thenReturn(new DataStoreIterableBuilder(loaded).build()); asyncAPIDAO.updateStatusAsyncAPIByFilter(filter, QueryStatus.TIMEDOUT, asyncQuery.getClass()); verify(tx, times(2)).save(any(AsyncQuery.class), any(RequestScope.class)); verify(asyncQuery, times(2)).setStatus(QueryStatus.TIMEDOUT); @@ -95,7 +96,7 @@ public void testUpdateStatusAsyncQueryCollection() { @Test public void testDeleteAsyncQueryAndResultCollection() { Iterable loaded = Arrays.asList(asyncQuery, asyncQuery, asyncQuery); - when(tx.loadObjects(any(), any())).thenReturn(loaded); + when(tx.loadObjects(any(), any())).thenReturn(new DataStoreIterableBuilder(loaded).build()); asyncAPIDAO.deleteAsyncAPIAndResultByFilter(filter, asyncQuery.getClass()); verify(dataStore, times(1)).beginTransaction(); verify(tx, times(1)).loadObjects(any(), any()); @@ -117,7 +118,7 @@ public void testUpdateAsyncQueryResult() { @Test public void testLoadAsyncQueryCollection() { Iterable loaded = Arrays.asList(asyncQuery, asyncQuery, asyncQuery); - when(tx.loadObjects(any(), any())).thenReturn(loaded); + when(tx.loadObjects(any(), any())).thenReturn(new DataStoreIterableBuilder(loaded).build()); asyncAPIDAO.loadAsyncAPIByFilter(filter, asyncQuery.getClass()); verify(dataStore, times(1)).beginTransaction(); verify(tx, times(1)).loadObjects(any(), any()); diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngineTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngineTest.java index d6fb9d7ab5..4f0fb2a45c 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngineTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/FileResultStorageEngineTest.java @@ -67,7 +67,7 @@ public void testStoreResultsFail(@TempDir File tempDir) { } private String readResultsFile(String path, String queryId) { - FileResultStorageEngine engine = new FileResultStorageEngine(path); + FileResultStorageEngine engine = new FileResultStorageEngine(path, false); return engine.getResultsByID(queryId).collect(() -> new StringBuilder(), (resultBuilder, tempResult) -> { @@ -80,7 +80,7 @@ private String readResultsFile(String path, String queryId) { } private void storeResultsFile(String path, String queryId, Observable storable) { - FileResultStorageEngine engine = new FileResultStorageEngine(path); + FileResultStorageEngine engine = new FileResultStorageEngine(path, false); TableExport query = new TableExport(); query.setId(queryId); diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngineTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngineTest.java new file mode 100644 index 0000000000..3634e25f20 --- /dev/null +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/storageengine/RedisResultStorageEngineTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.async.service.storageengine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.yahoo.elide.async.models.TableExport; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import io.reactivex.Observable; +import io.reactivex.observers.TestObserver; +import redis.clients.jedis.JedisPooled; +import redis.clients.jedis.exceptions.JedisConnectionException; +import redis.embedded.RedisServer; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +/** + * Test cases for RedisResultStorageEngine. + */ +public class RedisResultStorageEngineTest { + private static final String HOST = "localhost"; + private static final int PORT = 6379; + private static final int EXPIRATION_SECONDS = 120; + private static final int BATCH_SIZE = 2; + private static final boolean EXTENSION_SUPPORT = false; + private JedisPooled jedisPool; + private RedisServer redisServer; + RedisResultStorageEngine engine; + + @BeforeEach + public void setup() throws IOException { + redisServer = new RedisServer(PORT); + redisServer.start(); + jedisPool = new JedisPooled(HOST, PORT); + engine = new RedisResultStorageEngine(jedisPool, EXTENSION_SUPPORT, EXPIRATION_SECONDS, BATCH_SIZE); + } + + @AfterEach + public void destroy() throws IOException { + redisServer.stop(); + } + + @Test + public void testReadNonExistent() { + assertThrows(IllegalStateException.class, () -> + verifyResults("nonexisting_results", Arrays.asList("")) + ); + } + + @Test + public void testStoreEmptyResults() { + String queryId = "store_empty_results_success"; + String validOutput = ""; + String[] input = validOutput.split("\n"); + + storeResults(queryId, Observable.fromArray(input)); + + // verify contents of stored files are readable and match original + verifyResults("store_empty_results_success", Arrays.asList(validOutput)); + } + + @Test + public void testStoreResults() { + String queryId = "store_results_success"; + String validOutput = "hi\nhello"; + String[] input = validOutput.split("\n"); + + storeResults(queryId, Observable.fromArray(input)); + + // verify contents of stored files are readable and match original + verifyResults("store_results_success", Arrays.asList(validOutput)); + } + + // Redis server does not exist. + @Test + public void testStoreResultsFail() throws IOException { + destroy(); + assertThrows(JedisConnectionException.class, () -> + storeResults("store_results_fail", + Observable.fromArray(new String[]{"hi", "hello"})) + ); + } + + @Test + public void testReadResultsBatch() { + String queryId = "store_results_batch_success"; + // 3 records > batchSize i.e 2 + String validOutput = "hi\nhello\nbye"; + String[] input = validOutput.split("\n"); + + storeResults(queryId, Observable.fromArray(input)); + + // 2 onnext calls will be returned. + // 1st call will have 2 records combined together as one. hi and hello. + // 2nd call will have 1 record only. bye. + verifyResults("store_results_batch_success", Arrays.asList("hi\nhello", "bye")); + } + + private void verifyResults(String queryId, List expected) { + TestObserver subscriber = new TestObserver<>(); + + Observable observable = engine.getResultsByID(queryId); + + observable.subscribe(subscriber); + subscriber.assertComplete(); + assertEquals(subscriber.getEvents().iterator().next(), expected); + } + + private void storeResults(String queryId, Observable storable) { + TableExport query = new TableExport(); + query.setId(queryId); + + engine.storeResults(query, storable); + } +} diff --git a/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnableTest.java b/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnableTest.java index 2b7f087043..c79afa6163 100644 --- a/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnableTest.java +++ b/elide-async/src/test/java/com/yahoo/elide/async/service/thread/AsyncAPICancelRunnableTest.java @@ -22,7 +22,6 @@ import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; import com.yahoo.elide.core.dictionary.EntityDictionary; -import com.yahoo.elide.core.filter.dialect.ParseException; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.security.checks.Check; import com.yahoo.elide.core.utils.DefaultClassScanner; @@ -92,7 +91,7 @@ public void testActiveTransactionCancellation() { } @Test - public void testStatusBasedFilter() throws ParseException { + public void testStatusBasedFilter() { DataStoreTransaction dtx = elide.getDataStore().beginTransaction(); transactionRegistry.addRunningTransaction(UUID.fromString("edc4a871-dff2-4054-804e-d80075cf828d"), dtx); transactionRegistry.addRunningTransaction(UUID.fromString("edc4a871-dff2-4054-804e-d80075cf827d"), dtx); diff --git a/elide-core/pom.xml b/elide-core/pom.xml index c067ac697e..5246961d5e 100644 --- a/elide-core/pom.xml +++ b/elide-core/pom.xml @@ -14,7 +14,7 @@ com.yahoo.elide elide-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -126,13 +126,13 @@ org.fusesource.jansi jansi - 2.3.4 + 2.4.0 io.github.classgraph classgraph - 4.8.115 + 4.8.149 @@ -169,7 +169,7 @@ com.google.inject guice - 5.0.1 + 5.1.0 test @@ -222,7 +222,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.3.0 + 3.4.1 attach-javadocs diff --git a/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Core.g4 b/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Core.g4 index f9afc5cc85..5d72bc06f6 100644 --- a/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Core.g4 +++ b/elide-core/src/main/antlr4/com/yahoo/elide/generated/parsers/Core.g4 @@ -30,24 +30,28 @@ relationship: RELATIONSHIPS '/' term; query: ; // Visitor performs query and outputs result +id: IDSTR | PATHSTR; term: PATHSTR; -id: PATHSTR; RELATIONSHIPS: 'relationships'; -PATHSTR: UNRESERVED+; +PATHSTR: ALPHA ( ALPHANUM | UNDERSCORE | HYPHEN )+; +IDSTR: UNRESERVED+; UNRESERVED : ALPHANUM | MARK + | UNDERSCORE + | HYPHEN ; MARK - : '-' - | '_' - | '.' + : '.' | '!' | '~' + | ':' + | ' ' + | '&' | '=' //For BASE64 IDs | '%' //For URL encoded IDs | '*' @@ -56,6 +60,9 @@ MARK | ')' ; +UNDERSCORE : '_'; +HYPHEN : '-'; + ALPHANUM : ALPHA | DIGIT diff --git a/elide-core/src/main/java/com/yahoo/elide/Elide.java b/elide-core/src/main/java/com/yahoo/elide/Elide.java index b661aa12f7..5070859dbf 100644 --- a/elide-core/src/main/java/com/yahoo/elide/Elide.java +++ b/elide-core/src/main/java/com/yahoo/elide/Elide.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -39,6 +39,7 @@ import com.yahoo.elide.jsonapi.parser.JsonApiParser; import com.yahoo.elide.jsonapi.parser.PatchVisitor; import com.yahoo.elide.jsonapi.parser.PostVisitor; +import com.fasterxml.jackson.core.JacksonException; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -82,35 +83,74 @@ public class Elide { @Getter private final ErrorMapper errorMapper; @Getter private final TransactionRegistry transactionRegistry; @Getter private final ClassScanner scanner; + private boolean initialized = false; /** * Instantiates a new Elide instance. * * @param elideSettings Elide settings object. */ - public Elide(ElideSettings elideSettings) { - this(elideSettings, elideSettings.getDictionary().getScanner()); + public Elide( + ElideSettings elideSettings + ) { + this(elideSettings, new TransactionRegistry(), elideSettings.getDictionary().getScanner(), false); } /** * Instantiates a new Elide instance. * * @param elideSettings Elide settings object. + * @param transactionRegistry Global transaction state. + */ + public Elide( + ElideSettings elideSettings, + TransactionRegistry transactionRegistry + ) { + this(elideSettings, transactionRegistry, elideSettings.getDictionary().getScanner(), false); + } + + /** + * Instantiates a new Elide instance. + * + * @param elideSettings Elide settings object. + * @param transactionRegistry Global transaction state. * @param scanner Scans classes for Elide annotations. + * @param doScans Perform scans now. */ - public Elide(ElideSettings elideSettings, ClassScanner scanner) { + public Elide( + ElideSettings elideSettings, + TransactionRegistry transactionRegistry, + ClassScanner scanner, + boolean doScans + ) { this.elideSettings = elideSettings; this.scanner = scanner; this.auditLogger = elideSettings.getAuditLogger(); this.dataStore = new InMemoryDataStore(elideSettings.getDataStore()); - this.dataStore.populateEntityDictionary(elideSettings.getDictionary()); this.mapper = elideSettings.getMapper(); this.errorMapper = elideSettings.getErrorMapper(); - this.transactionRegistry = new TransactionRegistry(); + this.transactionRegistry = transactionRegistry; + + if (doScans) { + doScans(); + } + } + + /** + * Scans & binds Elide models, scans for security check definitions, serde definitions, life cycle hooks + * and more. Any dependency injection required by objects found from scans must be performed prior to this call. + */ + public void doScans() { + if (! initialized) { + elideSettings.getSerdes().forEach((type, serde) -> registerCustomSerde(type, serde, type.getSimpleName())); + registerCustomSerde(); - elideSettings.getSerdes().forEach((type, serde) -> registerCustomSerde(type, serde, type.getSimpleName())); + //Scan for security checks prior to populating data stores in case they need them. + elideSettings.getDictionary().scanForSecurityChecks(); - registerCustomSerde(); + this.dataStore.populateEntityDictionary(elideSettings.getDictionary()); + initialized = true; + } } protected void registerCustomSerde() { @@ -443,7 +483,7 @@ public ElideResponse delete(String baseUrlEndPoint, String path, String jsonApiD public HandlerResult visit(String path, RequestScope requestScope, BaseVisitor visitor) { try { - Supplier> responder = visitor.visit(JsonApiParser.parse(path)); + Supplier> responder = visitor.visit(JsonApiParser.parse(path)); return new HandlerResult(requestScope, responder); } catch (RuntimeException e) { return new HandlerResult(requestScope, e); @@ -458,9 +498,10 @@ public HandlerResult visit(String path, RequestScope requestScope, BaseVisitor v * @param transaction a transaction supplier * @param requestId the Request ID * @param handler a function that creates the request scope and request handler + * @param The response type (JsonNode or JsonApiDocument) * @return the response */ - protected ElideResponse handleRequest(boolean isReadOnly, User user, + protected ElideResponse handleRequest(boolean isReadOnly, User user, Supplier transaction, UUID requestId, Handler handler) { boolean isVerbose = false; @@ -469,14 +510,14 @@ protected ElideResponse handleRequest(boolean isReadOnly, User user, HandlerResult result = handler.handle(tx, user); RequestScope requestScope = result.getRequestScope(); isVerbose = requestScope.getPermissionExecutor().isVerbose(); - Supplier> responder = result.getResponder(); + Supplier> responder = result.getResponder(); tx.preCommit(requestScope); requestScope.runQueuedPreSecurityTriggers(); requestScope.getPermissionExecutor().executeCommitChecks(); + requestScope.runQueuedPreFlushTriggers(); if (!isReadOnly) { requestScope.saveOrCreateObjects(); } - requestScope.runQueuedPreFlushTriggers(); tx.flush(requestScope); requestScope.runQueuedPreCommitTriggers(); @@ -492,10 +533,8 @@ protected ElideResponse handleRequest(boolean isReadOnly, User user, } return response; - } catch (IOException e) { - log.error("IO Exception uncaught by Elide", e); - return buildErrorResponse(new TransactionException(e), isVerbose); + return handleNonRuntimeException(e, isVerbose); } catch (RuntimeException e) { return handleRuntimeException(e, isVerbose); } finally { @@ -513,6 +552,31 @@ protected ElideResponse buildErrorResponse(HttpStatusException error, boolean is : error.getErrorResponse()); } + private ElideResponse handleNonRuntimeException(Exception error, boolean isVerbose) { + CustomErrorException mappedException = mapError(error); + if (mappedException != null) { + return buildErrorResponse(mappedException, isVerbose); + } + + if (error instanceof JacksonException) { + JacksonException jacksonException = (JacksonException) error; + String message = (jacksonException.getLocation() != null + && jacksonException.getLocation().getSourceRef() != null) + ? error.getMessage() //This will leak Java class info if the location isn't known. + : jacksonException.getOriginalMessage(); + + return buildErrorResponse(new BadRequestException(message), isVerbose); + } + + if (error instanceof IOException) { + log.error("IO Exception uncaught by Elide", error); + return buildErrorResponse(new TransactionException(error), isVerbose); + } + + log.error("Error or exception uncaught by Elide", error); + throw new RuntimeException(error); + } + private ElideResponse handleRuntimeException(RuntimeException error, boolean isVerbose) { CustomErrorException mappedException = mapError(error); @@ -575,7 +639,7 @@ private ElideResponse handleRuntimeException(RuntimeException error, boolean isV throw new RuntimeException(error); } - public CustomErrorException mapError(RuntimeException error) { + public CustomErrorException mapError(Exception error) { if (errorMapper != null) { log.trace("Attempting to map unknown exception of type {}", error.getClass()); CustomErrorException customizedError = errorMapper.map(error); @@ -592,9 +656,9 @@ public CustomErrorException mapError(RuntimeException error) { return null; } - protected ElideResponse buildResponse(Pair response) { + protected ElideResponse buildResponse(Pair response) { try { - JsonNode responseNode = response.getRight(); + T responseNode = response.getRight(); Integer responseCode = response.getLeft(); String body = responseNode == null ? null : mapper.writeJsonApiDocument(responseNode); return new ElideResponse(responseCode, body); @@ -637,13 +701,14 @@ public interface Handler { /** * A wrapper to return multiple values, less verbose than Pair. + * @param Response type. */ - protected static class HandlerResult { + protected static class HandlerResult { protected RequestScope requestScope; - protected Supplier> result; + protected Supplier> result; protected RuntimeException cause; - protected HandlerResult(RequestScope requestScope, Supplier> result) { + protected HandlerResult(RequestScope requestScope, Supplier> result) { this.requestScope = requestScope; this.result = result; } @@ -653,7 +718,7 @@ public HandlerResult(RequestScope requestScope, RuntimeException cause) { this.cause = cause; } - public Supplier> getResponder() { + public Supplier> getResponder() { if (cause != null) { throw cause; } diff --git a/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java b/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java index b9d4c0b11b..40f27817c0 100644 --- a/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java +++ b/elide-core/src/main/java/com/yahoo/elide/ElideSettings.java @@ -17,6 +17,7 @@ import com.yahoo.elide.core.utils.coerce.converters.Serde; import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.links.JSONApiLinks; +import com.yahoo.elide.utils.HeaderUtils; import lombok.AllArgsConstructor; import lombok.Getter; @@ -39,12 +40,14 @@ public class ElideSettings { @Getter private final List subqueryFilterDialects; @Getter private final FilterDialect graphqlDialect; @Getter private final JSONApiLinks jsonApiLinks; + @Getter private final HeaderUtils.HeaderProcessor headerProcessor; @Getter private final int defaultMaxPageSize; @Getter private final int defaultPageSize; @Getter private final int updateStatusCode; @Getter private final Map serdes; @Getter private final boolean enableJsonLinks; @Getter private final boolean strictQueryParams; + @Getter private final boolean enableGraphQLFederation; @Getter private final String baseUrl; @Getter private final String jsonApiPath; @Getter private final String graphQLApiPath; diff --git a/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java b/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java index ea1446952c..c659704c49 100644 --- a/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java +++ b/elide-core/src/main/java/com/yahoo/elide/ElideSettingsBuilder.java @@ -30,13 +30,14 @@ import com.yahoo.elide.core.utils.coerce.converters.URLSerde; import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.links.JSONApiLinks; +import com.yahoo.elide.utils.HeaderUtils; import java.net.URL; import java.time.Instant; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.Date; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.TimeZone; @@ -56,11 +57,14 @@ public class ElideSettingsBuilder { private List subqueryFilterDialects; private FilterDialect graphqlFilterDialect; private JSONApiLinks jsonApiLinks; + private HeaderUtils.HeaderProcessor headerProcessor; private Map serdes; private int defaultMaxPageSize = PaginationImpl.MAX_PAGE_LIMIT; private int defaultPageSize = PaginationImpl.DEFAULT_PAGE_LIMIT; private int updateStatusCode; private boolean enableJsonLinks; + + private boolean enableGraphQLFederation; private boolean strictQueryParams = true; private String baseUrl = ""; private String jsonApiPath; @@ -79,9 +83,11 @@ public ElideSettingsBuilder(DataStore dataStore) { this.jsonApiMapper = new JsonApiMapper(); this.joinFilterDialects = new ArrayList<>(); this.subqueryFilterDialects = new ArrayList<>(); + this.headerProcessor = HeaderUtils::lowercaseAndRemoveAuthHeaders; updateStatusCode = HttpStatus.SC_NO_CONTENT; - this.serdes = new HashMap<>(); + this.serdes = new LinkedHashMap<>(); this.enableJsonLinks = false; + this.enableGraphQLFederation = false; //By default, Elide supports epoch based dates. this.withEpochDates(); @@ -91,16 +97,16 @@ public ElideSettingsBuilder(DataStore dataStore) { public ElideSettings build() { if (joinFilterDialects.isEmpty()) { joinFilterDialects.add(new DefaultFilterDialect(entityDictionary)); - joinFilterDialects.add(new RSQLFilterDialect(entityDictionary)); + joinFilterDialects.add(RSQLFilterDialect.builder().dictionary(entityDictionary).build()); } if (subqueryFilterDialects.isEmpty()) { subqueryFilterDialects.add(new DefaultFilterDialect(entityDictionary)); - subqueryFilterDialects.add(new RSQLFilterDialect(entityDictionary)); + subqueryFilterDialects.add(RSQLFilterDialect.builder().dictionary(entityDictionary).build()); } if (graphqlFilterDialect == null) { - graphqlFilterDialect = new RSQLFilterDialect(entityDictionary); + graphqlFilterDialect = RSQLFilterDialect.builder().dictionary(entityDictionary).build(); } if (entityDictionary == null) { @@ -118,12 +124,14 @@ public ElideSettings build() { subqueryFilterDialects, graphqlFilterDialect, jsonApiLinks, + headerProcessor, defaultMaxPageSize, defaultPageSize, updateStatusCode, serdes, enableJsonLinks, strictQueryParams, + enableGraphQLFederation, baseUrl, jsonApiPath, graphQLApiPath, @@ -231,6 +239,11 @@ public ElideSettingsBuilder withJSONApiLinks(JSONApiLinks links) { return this; } + public ElideSettingsBuilder withHeaderProcessor(HeaderUtils.HeaderProcessor headerProcessor) { + this.headerProcessor = headerProcessor; + return this; + } + public ElideSettingsBuilder withJsonApiPath(String jsonApiPath) { this.jsonApiPath = jsonApiPath; return this; @@ -250,4 +263,9 @@ public ElideSettingsBuilder withStrictQueryParams(boolean enabled) { this.strictQueryParams = enabled; return this; } + + public ElideSettingsBuilder withGraphQLFederation(boolean enabled) { + this.enableGraphQLFederation = enabled; + return this; + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/RefreshableElide.java b/elide-core/src/main/java/com/yahoo/elide/RefreshableElide.java new file mode 100644 index 0000000000..0b164acf2f --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/RefreshableElide.java @@ -0,0 +1,22 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide; + +import lombok.Getter; + +/** + * Wraps an Elide instance that can be hot reloaded at runtime. This class is restricted to + * a single access method (getElide) to eliminate state issues across reloads. + */ +public class RefreshableElide { + @Getter + private Elide elide; + + public RefreshableElide(Elide elide) { + this.elide = elide; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java b/elide-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java index 666cf312aa..b1e0931131 100644 --- a/elide-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java +++ b/elide-core/src/main/java/com/yahoo/elide/annotation/LifeCycleHookBinding.java @@ -21,9 +21,14 @@ @Repeatable(LifeCycleHookBindings.class) public @interface LifeCycleHookBinding { + Operation [] ALL_OPERATIONS = { + Operation.CREATE, + Operation.UPDATE, + Operation.DELETE + }; + enum Operation { CREATE, - READ, UPDATE, DELETE }; diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/OnReadPostCommit.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnReadPostCommit.java deleted file mode 100644 index 768dc9ae9b..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/annotation/OnReadPostCommit.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Post-read hook. This annotation marks a callback that is triggered when a user performs a "read" action. - * This hook will be triggered after all security checks have been run and after the datastore - * has been committed. - *

- * The invoked function takes a RequestScope as parameter. - * - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -@Deprecated -public @interface OnReadPostCommit { - /** - * Field name on which the annotated method is only triggered if that field is read. - * If value is empty string, then trigger once when the object is read. - * If value is "*", then trigger for all field reads. - * - * @return the field name that triggers the method - */ - String value() default ""; -} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/OnReadPreCommit.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnReadPreCommit.java deleted file mode 100644 index 8291db6046..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/annotation/OnReadPreCommit.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Pre-read hook. This annotation marks a callback that is triggered when a user performs a "read" action. - * This hook will be triggered after all security checks have been run, but before the datastore - * has been committed. - *

- * The invoked function takes a RequestScope as parameter. - * - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -@Deprecated -public @interface OnReadPreCommit { - /** - * Field name on which the annotated method is only triggered if that field is read. - * If value is empty string, then trigger once when the object is read. - * If value is "*", then trigger for all field reads. - * - * @return the field name that triggers the method - */ - String value() default ""; -} diff --git a/elide-core/src/main/java/com/yahoo/elide/annotation/OnReadPreSecurity.java b/elide-core/src/main/java/com/yahoo/elide/annotation/OnReadPreSecurity.java deleted file mode 100644 index a80bf9475e..0000000000 --- a/elide-core/src/main/java/com/yahoo/elide/annotation/OnReadPreSecurity.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2016, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * On read trigger annotation. - *

- * The invoked function takes a RequestScope as parameter. - * - */ -@Target({ElementType.METHOD}) -@Retention(RetentionPolicy.RUNTIME) -@Deprecated -public @interface OnReadPreSecurity { - /** - * Field name on which the annotated method is only triggered if that field is read. - * If value is empty string, then trigger once when the object is read. - * If value is "*", then trigger for all field reads. - * - * @return the field name that triggers this method - */ - String value() default ""; -} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/Path.java b/elide-core/src/main/java/com/yahoo/elide/core/Path.java index 44c6741e18..970d78eabc 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/Path.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/Path.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -92,6 +92,18 @@ public Path(Type entityClass, EntityDictionary dictionary, String fieldName, pathElements = Lists.newArrayList(resolvePathAttribute(entityClass, fieldName, alias, arguments, dictionary)); } + public boolean isComputed(EntityDictionary dictionary) { + for (Path.PathElement pathElement : getPathElements()) { + Type entityClass = pathElement.getType(); + String fieldName = pathElement.getFieldName(); + + if (dictionary.isComputed(entityClass, fieldName)) { + return true; + } + } + return false; + } + /** * Resolve a dot separated path into list of path elements. * diff --git a/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java b/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java index 1c9e99bcac..af1077d34f 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/PersistentResource.java @@ -7,8 +7,9 @@ import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.DELETE; -import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; +import static com.yahoo.elide.core.dictionary.EntityBinding.EMPTY_BINDING; +import static com.yahoo.elide.core.dictionary.EntityDictionary.getType; import static com.yahoo.elide.core.type.ClassType.COLLECTION_TYPE; import com.yahoo.elide.annotation.Audit; @@ -21,7 +22,9 @@ import com.yahoo.elide.core.audit.InvalidSyntaxException; import com.yahoo.elide.core.audit.LogMessage; import com.yahoo.elide.core.audit.LogMessageImpl; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.EntityBinding; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.dictionary.RelationshipType; import com.yahoo.elide.core.exceptions.BadRequestException; @@ -45,7 +48,9 @@ import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.jsonapi.document.processors.WithMetadata; import com.yahoo.elide.jsonapi.models.Data; +import com.yahoo.elide.jsonapi.models.Meta; import com.yahoo.elide.jsonapi.models.Relationship; import com.yahoo.elide.jsonapi.models.Resource; import com.yahoo.elide.jsonapi.models.ResourceIdentifier; @@ -68,6 +73,7 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -124,7 +130,7 @@ public PersistentResource( @NonNull RequestScope scope ) { this.obj = obj; - this.type = EntityDictionary.getType(obj); + this.type = getType(obj); this.uuid = Optional.ofNullable(id); this.lineage = parent != null ? new ResourceLineage(parent.lineage, parent, parentRelationship) @@ -496,7 +502,7 @@ private static ExpressionResult checkPermission( private static ExpressionResult checkUserPermission( Class annotationClass, Object obj, RequestScope requestScope, Set requestedFields) { return requestScope.getPermissionExecutor() - .checkUserPermissions(EntityDictionary.getType(obj), annotationClass, requestedFields); + .checkUserPermissions(getType(obj), annotationClass, requestedFields); } protected static boolean checkIncludeSparseField(Map> sparseFields, String type, @@ -580,18 +586,31 @@ public boolean updateAttribute(String fieldName, Object newVal) { if (!Objects.equals(val, coercedNewValue)) { if (val == null || coercedNewValue == null - || !dictionary.isComplexAttribute(EntityDictionary.getType(obj), fieldName)) { + || !dictionary.isComplexAttribute(getType(obj), fieldName)) { this.setValueChecked(fieldName, coercedNewValue); } else { if (newVal instanceof Map) { - this.updateComplexAttribute(dictionary, (Map) newVal, val, requestScope); + + //We perform a copy here for two reasons: + //1. We want the original so we can dispatch update life cycle hooks. + //2. Some stores (Hibernate) won't notice changes to an attribute if the attribute + //has a @TypeDef annotation unless we modify the reference in the parent object. This rules + //out an update in place strategy. + Object copy = copyComplexAttribute(val); + + //Update the copy. + this.updateComplexAttribute(dictionary, (Map) newVal, copy, requestScope); + + //Set the copy. + dictionary.setValue(obj, fieldName, copy); + triggerUpdate(fieldName, val, copy); } else { this.setValueChecked(fieldName, coercedNewValue); } } this.markDirty(); //Hooks for customize logic for setAttribute/Relation - if (dictionary.isAttribute(EntityDictionary.getType(obj), fieldName)) { + if (dictionary.isAttribute(getType(obj), fieldName)) { transaction.setAttribute(obj, Attribute.builder() .name(fieldName) .type(fieldClass) @@ -630,6 +649,43 @@ private void updateComplexAttribute(EntityDictionary dictionary, } } + /** + * Copies a complex attribute. If the attribute fields are complex, recurses to perform a deep copy. + * @param object The attribute to copy. + * @return The copy. + */ + private Object copyComplexAttribute(Object object) { + if (object == null) { + return null; + } + + Type type = getType(object); + EntityBinding binding = dictionary.getEntityBinding(type); + + Preconditions.checkState(! binding.equals(EMPTY_BINDING), "Model not found."); + Preconditions.checkState(binding.apiRelationships.isEmpty(), "Deep copy of relationships not supported"); + + Object copy; + try { + copy = type.newInstance(); + } catch (InstantiationException | IllegalAccessException e) { + throw new IllegalStateException("Cannot perform deep copy of " + type.getName(), e); + } + + binding.apiAttributes.forEach(attribute -> { + Object newValue; + Object oldValue = dictionary.getValue(object, attribute, requestScope); + if (! dictionary.isComplexAttribute(type, attribute)) { + newValue = oldValue; + } else { + newValue = copyComplexAttribute(oldValue); + } + dictionary.setValue(copy, attribute, newValue); + }); + + return copy; + } + /** * Perform a full replacement on relationships. * Here is an example: @@ -724,31 +780,24 @@ protected boolean updateToManyRelation(String fieldName, checkTransferablePermission(added); - Collection collection = (Collection) this.getValueUnchecked(fieldName); - - if (collection == null) { - this.setValue(fieldName, mine); - } - Set newRelationships = new LinkedHashSet<>(); Set deletedRelationships = new LinkedHashSet<>(); deleted .stream() .forEach(toDelete -> { - delFromCollection(collection, fieldName, toDelete, false); - deleteInverseRelation(fieldName, toDelete.getObject()); deletedRelationships.add(toDelete.getObject()); }); added .stream() .forEach(toAdd -> { - addToCollection(collection, fieldName, toAdd); - addInverseRelation(fieldName, toAdd.getObject()); newRelationships.add(toAdd.getObject()); }); + Collection collection = (Collection) this.getValueUnchecked(fieldName); + modifyCollection(collection, fieldName, newRelationships, deletedRelationships, true); + if (!updated.isEmpty()) { this.markDirty(); } @@ -830,14 +879,6 @@ public boolean clearRelation(String relationName) { RelationshipType type = getRelationshipType(relationName); - mine.stream() - .forEach(toDelete -> { - if (hasInverseRelation(relationName)) { - deleteInverseRelation(relationName, toDelete.getObject()); - toDelete.markDirty(); - } - }); - if (type.isToOne()) { PersistentResource oldValue = IterableUtils.first(mine); if (oldValue != null && oldValue.getObject() != null) { @@ -854,12 +895,9 @@ public boolean clearRelation(String relationName) { Set deletedRelationships = new LinkedHashSet<>(); mine.stream() .forEach(toDelete -> { - delFromCollection(collection, relationName, toDelete, false); - if (hasInverseRelation(relationName)) { - toDelete.markDirty(); - } deletedRelationships.add(toDelete.getObject()); }); + modifyCollection(collection, relationName, Collections.emptySet(), deletedRelationships, true); this.markDirty(); //hook for updateToManyRelation transaction.updateToManyRelation(transaction, obj, relationName, @@ -900,18 +938,19 @@ public void removeRelation(String fieldName, PersistentResource removeResource) //Nothing to do return; } - delFromCollection((Collection) relation, fieldName, removeResource, false); + modifyCollection((Collection) relation, fieldName, Collections.emptySet(), + Set.of(removeResource.getObject()), true); } else { if (relation == null || removeResource == null || !relation.equals(removeResource.getObject())) { //Nothing to do return; } this.nullValue(fieldName, removeResource); - } - if (hasInverseRelation(fieldName)) { - deleteInverseRelation(fieldName, removeResource.getObject()); - removeResource.markDirty(); + if (hasInverseRelation(fieldName)) { + deleteInverseRelation(fieldName, removeResource.getObject()); + removeResource.markDirty(); + } } if (!Objects.equals(original, modified)) { @@ -929,28 +968,6 @@ public void removeRelation(String fieldName, PersistentResource removeResource) } } - /** - * Checks whether the new entity is already a part of the relationship. - * - * @param fieldName which relation link - * @param toAdd the new relation - * @return boolean representing the result of the check - *

- * This method does not handle the case where the toAdd entity is newly created, - * since newly created entities have null id there is no easy way to check for identity - */ - public boolean relationshipAlreadyExists(String fieldName, PersistentResource toAdd) { - Object relation = this.getValueUnchecked(fieldName); - String toAddId = toAdd.getId(); - if (toAddId == null) { - return false; - } - if (relation instanceof Collection) { - return ((Collection) relation).stream().anyMatch(obj -> toAddId.equals(dictionary.getId(obj))); - } - return toAddId.equals(dictionary.getId(relation)); - } - /** * Add relation link from a given parent resource to a child resource. * @@ -958,21 +975,20 @@ public boolean relationshipAlreadyExists(String fieldName, PersistentResource to * @param newRelation the new relation */ public void addRelation(String fieldName, PersistentResource newRelation) { - if (!newRelation.isNewlyCreated() && relationshipAlreadyExists(fieldName, newRelation)) { - return; - } checkTransferablePermission(Collections.singleton(newRelation)); Object relation = this.getValueUnchecked(fieldName); if (relation instanceof Collection) { - if (addToCollection((Collection) relation, fieldName, newRelation)) { + if (modifyCollection((Collection) relation, fieldName, + Set.of(newRelation.getObject()), Collections.emptySet(), true)) { this.markDirty(); - } - //Hook for updateToManyRelation - transaction.updateToManyRelation(transaction, obj, fieldName, - Sets.newHashSet(newRelation.getObject()), new LinkedHashSet<>(), requestScope); - addInverseRelation(fieldName, newRelation.getObject()); + //Hook for updateToManyRelation + transaction.updateToManyRelation(transaction, obj, fieldName, + Sets.newHashSet(newRelation.getObject()), new LinkedHashSet<>(), requestScope); + + addInverseRelation(fieldName, newRelation.getObject()); + } } else { // Not a collection, but may be trying to create a ToOne relationship. // NOTE: updateRelation marks dirty. @@ -1221,12 +1237,6 @@ private void assertPropertyExists(String propertyName) { private Observable getRelation(com.yahoo.elide.core.request.Relationship relationship, boolean checked) { - if (checked) { - //All getRelation calls funnel to here. We only publish events for actions triggered directly - //by the API client. - requestScope.publishLifecycleEvent(this, READ); - requestScope.publishLifecycleEvent(this, relationship.getName(), READ, Optional.empty()); - } if (checked && !checkRelation(relationship)) { return Observable.empty(); @@ -1324,19 +1334,21 @@ private Observable getRelationUnchecked( .build() ).build(); - Object val = transaction.getRelation(transaction, obj, modifiedRelationship, requestScope); - - if (val == null) { - return Observable.empty(); - } - Observable resources; - if (val instanceof Iterable) { - Iterable filteredVal = (Iterable) val; + if (type.isToMany()) { + DataStoreIterable val = transaction.getToManyRelation(transaction, obj, modifiedRelationship, requestScope); + + if (val == null) { + return Observable.empty(); + } resources = Observable.fromIterable( - new PersistentResourceSet(this, relationName, filteredVal, requestScope)); + new PersistentResourceSet(this, relationName, val, requestScope)); } else { + Object val = transaction.getToOneRelation(transaction, obj, modifiedRelationship, requestScope); + if (val == null) { + return Observable.empty(); + } resources = Observable.fromArray(new PersistentResource(val, this, relationName, requestScope.getUUIDFor(val), requestScope)); } @@ -1409,7 +1421,7 @@ public void setObject(T obj) { @Override @JsonIgnore public Type getResourceType() { - return (Type) dictionary.lookupBoundClass(EntityDictionary.getType(obj)); + return (Type) dictionary.lookupBoundClass(getType(obj)); } /** @@ -1544,6 +1556,26 @@ public Resource toResource(final Map relationships, if (requestScope.getElideSettings().isEnableJsonLinks()) { resource.setLinks(requestScope.getElideSettings().getJsonApiLinks().getResourceLevelLinks(this)); } + + if (! (getObject() instanceof WithMetadata)) { + return resource; + } + + WithMetadata withMetadata = (WithMetadata) getObject(); + Set fields = withMetadata.getMetadataFields(); + + if (fields.size() == 0) { + return resource; + } + + Meta meta = new Meta(new HashMap<>()); + + for (String field : fields) { + meta.getMetaMap().put(field, withMetadata.getMetadataField(field).get()); + } + + resource.setMeta(meta); + return resource; } @@ -1677,8 +1709,6 @@ protected void nullValue(String fieldName, PersistentResource oldValue) { * @return value value */ protected Object getValueChecked(Attribute attribute) { - requestScope.publishLifecycleEvent(this, READ); - requestScope.publishLifecycleEvent(this, attribute.getName(), READ, Optional.empty()); checkFieldAwareDeferPermissions(ReadPermission.class, attribute.getName(), null, null); return transaction.getAttribute(getObject(), attribute, requestScope); } @@ -1693,94 +1723,47 @@ protected Object getValueUnchecked(String fieldName) { return getValue(getObject(), fieldName, requestScope); } - /** - * Adds a new element to a collection and tests update permission. - * - * @param collection the collection - * @param collectionName the collection name - * @param toAdd the to add - * @return True if added to collection false otherwise (i.e. element already in collection) - */ - protected boolean addToCollection(Collection collection, String collectionName, PersistentResource toAdd) { - final Collection singleton = Collections.singleton(toAdd.getObject()); - final Collection original = copyCollection(collection); - checkFieldAwareDeferPermissions( - UpdatePermission.class, - collectionName, - CollectionUtils.union(CollectionUtils.emptyIfNull(collection), singleton), - original); - if (collection == null) { - collection = Collections.singleton(toAdd.getObject()); - Object value = getValueUnchecked(collectionName); - if (!Objects.equals(value, toAdd.getObject())) { - this.setValueChecked(collectionName, collection); - return true; - } - } else { - if (!collection.contains(toAdd.getObject())) { - collection.add(toAdd.getObject()); + protected boolean modifyCollection( + Collection toModify, + String collectionName, + Collection toAdd, + Collection toRemove, + boolean updateInverse) { - triggerUpdate(collectionName, original, collection); - return true; - } - } - return false; - } + Collection copyOfOriginal = copyCollection(toModify); + + Collection modified = CollectionUtils.union(CollectionUtils.emptyIfNull(toModify), toAdd); + modified = CollectionUtils.subtract(modified, toRemove); - /** - * Deletes an existing element in a collection and tests update and delete permissions. - * - * @param collection the collection - * @param collectionName the collection name - * @param toDelete the to delete - * @param isInverseCheck Whether or not the deletion is already coming from cleaning up an inverse. - * Without this parameter, we could find ourselves in a loop of checks. - * TODO: This is a band-aid for a quick fix. This should certainly be refactored. - */ - protected void delFromCollection( - Collection collection, - String collectionName, - PersistentResource toDelete, - boolean isInverseCheck) { - final Collection original = copyCollection(collection); checkFieldAwareDeferPermissions( UpdatePermission.class, collectionName, - CollectionUtils.disjunction(collection, Collections.singleton(toDelete.getObject())), - original - ); - - String inverseField = getInverseRelationField(collectionName); - if (!isInverseCheck && !inverseField.isEmpty()) { - // Compute the ChangeSpec for the inverse relation and check whether or not we have access - // to apply this change to that field. - final Object originalValue = toDelete.getValueUnchecked(inverseField); - final Collection originalBidirectional; + modified, + copyOfOriginal); - if (originalValue instanceof Collection) { - originalBidirectional = copyCollection((Collection) originalValue); - } else { - originalBidirectional = Collections.singleton(originalValue); + if (updateInverse) { + for (Object adding : toAdd) { + addInverseRelation(collectionName, adding); } - final Collection removedBidrectional = CollectionUtils - .disjunction(Collections.singleton(this.getObject()), originalBidirectional); - - toDelete.checkFieldAwareDeferPermissions( - UpdatePermission.class, - inverseField, - removedBidrectional, - originalBidirectional - ); - } - - if (collection == null) { - return; + for (Object removing : toRemove) { + deleteInverseRelation(collectionName, removing); + } } - collection.remove(toDelete.getObject()); + if (toModify == null) { + this.setValueChecked(collectionName, modified); + return true; + } else { + if (copyOfOriginal.equals(modified)) { + return false; + } + toModify.addAll(toAdd); + toModify.removeAll(toRemove); - triggerUpdate(collectionName, original, collection); + triggerUpdate(collectionName, copyOfOriginal, modified); + return true; + } } /** @@ -1820,7 +1803,8 @@ protected void deleteInverseRelation(String relationName, Object inverseEntity) } if (inverseRelation instanceof Collection) { - inverseResource.delFromCollection((Collection) inverseRelation, inverseField, this, true); + inverseResource.modifyCollection((Collection) inverseRelation, inverseField, + Collections.emptySet(), Set.of(this.getObject()), false); } else if (inverseType.isAssignableFrom(this.getResourceType())) { inverseResource.nullValue(inverseField, this); } else { @@ -1870,7 +1854,8 @@ protected void addInverseRelation(String relationName, Object inverseObj) { if (COLLECTION_TYPE.isAssignableFrom(inverseType)) { if (inverseRelation != null) { - inverseResource.addToCollection((Collection) inverseRelation, inverseName, this); + inverseResource.modifyCollection((Collection) inverseRelation, inverseName, + Set.of(this.getObject()), Collections.emptySet(), false); } else { inverseResource.setValueChecked(inverseName, Collections.singleton(this.getObject())); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java b/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java index c1ccdb8213..c32ae0854c 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/RequestScope.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.ALL_OPERATIONS; import com.yahoo.elide.ElideSettings; import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.core.audit.AuditLogger; @@ -27,9 +28,6 @@ import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import org.apache.commons.collections.MapUtils; -import io.reactivex.Observable; -import io.reactivex.subjects.PublishSubject; -import io.reactivex.subjects.ReplaySubject; import lombok.Getter; import lombok.Setter; @@ -76,9 +74,9 @@ public class RequestScope implements com.yahoo.elide.core.security.RequestScope @Getter private final UUID requestId; private final Map expressionsByType; - private PublishSubject lifecycleEvents; - private Observable distinctLifecycleEvents; - private ReplaySubject queuedLifecycleEvents; + private final Map metadata; + + private LinkedHashSet eventQueue; /* Used to filter across heterogeneous types during the first load */ private FilterExpression globalFilterExpression; @@ -107,10 +105,7 @@ public RequestScope(String baseUrlEndPoint, UUID requestId, ElideSettings elideSettings) { this.apiVersion = apiVersion; - this.lifecycleEvents = PublishSubject.create(); - this.distinctLifecycleEvents = lifecycleEvents.distinct(); - this.queuedLifecycleEvents = ReplaySubject.create(); - this.distinctLifecycleEvents.subscribe(queuedLifecycleEvents); + this.eventQueue = new LinkedHashSet<>(); this.path = path; this.baseUrlEndPoint = baseUrlEndPoint; @@ -132,12 +127,12 @@ public RequestScope(String baseUrlEndPoint, this.dirtyResources = new LinkedHashSet<>(); this.deletedResources = new LinkedHashSet<>(); this.requestId = requestId; + this.metadata = new HashMap<>(); this.queryParams = queryParams == null ? new MultivaluedHashMap<>() : queryParams; this.requestHeaders = MapUtils.isEmpty(requestHeaders) ? Collections.emptyMap() : requestHeaders; - registerPreSecurityObservers(); this.sparseFields = parseSparseFields(getQueryParams()); @@ -218,12 +213,11 @@ protected RequestScope(String path, String apiVersion, this.filterDialect = outerRequestScope.filterDialect; this.expressionsByType = outerRequestScope.expressionsByType; this.elideSettings = outerRequestScope.elideSettings; - this.lifecycleEvents = outerRequestScope.lifecycleEvents; - this.distinctLifecycleEvents = outerRequestScope.distinctLifecycleEvents; + this.eventQueue = outerRequestScope.eventQueue; this.updateStatusCode = outerRequestScope.updateStatusCode; - this.queuedLifecycleEvents = outerRequestScope.queuedLifecycleEvents; this.requestId = outerRequestScope.requestId; this.sparseFields = outerRequestScope.sparseFields; + this.metadata = new HashMap<>(outerRequestScope.metadata); } public Set getNewResources() { @@ -326,115 +320,59 @@ private static MultivaluedMap getFilterParams(MultivaluedMap event.getEventType().equals(operation)) + .forEach(event -> { + invoker.onNext(event); + }); + } + /** * Run queued pre-security lifecycle triggers. */ public void runQueuedPreSecurityTriggers() { - this.queuedLifecycleEvents - .filter(CRUDEvent::isCreateEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.CREATE, - LifeCycleHookBinding.TransactionPhase.PRESECURITY, false)) - .throwOnError(); + notifySubscribers(LifeCycleHookBinding.Operation.CREATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY); } /** * Run queued pre-flush lifecycle triggers. */ public void runQueuedPreFlushTriggers() { - this.queuedLifecycleEvents - .filter(CRUDEvent::isCreateEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.CREATE, - LifeCycleHookBinding.TransactionPhase.PREFLUSH, false)) - .throwOnError(); - - this.queuedLifecycleEvents - .filter(CRUDEvent::isUpdateEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.UPDATE, - LifeCycleHookBinding.TransactionPhase.PREFLUSH, false)) - .throwOnError(); - - this.queuedLifecycleEvents - .filter(CRUDEvent::isDeleteEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.DELETE, - LifeCycleHookBinding.TransactionPhase.PREFLUSH, false)) - .throwOnError(); - - this.queuedLifecycleEvents - .filter(CRUDEvent::isReadEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.READ, - LifeCycleHookBinding.TransactionPhase.PREFLUSH, false)) - .throwOnError(); + runQueuedPreFlushTriggers(ALL_OPERATIONS); + } + + /** + * Run queued pre-flush lifecycle triggers. + * @param operations List of operations to run pre-flush triggers for. + */ + public void runQueuedPreFlushTriggers(LifeCycleHookBinding.Operation[] operations) { + for (LifeCycleHookBinding.Operation op : operations) { + notifySubscribers(op, LifeCycleHookBinding.TransactionPhase.PREFLUSH); + } } /** * Run queued pre-commit lifecycle triggers. */ public void runQueuedPreCommitTriggers() { - this.queuedLifecycleEvents - .filter(CRUDEvent::isCreateEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.CREATE, - LifeCycleHookBinding.TransactionPhase.PRECOMMIT, false)) - .throwOnError(); - - this.queuedLifecycleEvents - .filter(CRUDEvent::isUpdateEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.UPDATE, - LifeCycleHookBinding.TransactionPhase.PRECOMMIT, false)) - .throwOnError(); - - this.queuedLifecycleEvents - .filter(CRUDEvent::isDeleteEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.DELETE, - LifeCycleHookBinding.TransactionPhase.PRECOMMIT, false)) - .throwOnError(); - - this.queuedLifecycleEvents - .filter(CRUDEvent::isReadEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.READ, - LifeCycleHookBinding.TransactionPhase.PRECOMMIT, false)) - .throwOnError(); + for (LifeCycleHookBinding.Operation op : ALL_OPERATIONS) { + notifySubscribers(op, LifeCycleHookBinding.TransactionPhase.PRECOMMIT); + } } /** * Run queued post-commit lifecycle triggers. */ public void runQueuedPostCommitTriggers() { - this.queuedLifecycleEvents - .filter(CRUDEvent::isCreateEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.CREATE, - LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, false)) - .throwOnError(); - - this.queuedLifecycleEvents - .filter(CRUDEvent::isUpdateEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.UPDATE, - LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, false)) - .throwOnError(); - - this.queuedLifecycleEvents - .filter(CRUDEvent::isDeleteEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.DELETE, - LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, false)) - .throwOnError(); - - this.queuedLifecycleEvents - .filter(CRUDEvent::isReadEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.READ, - LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, false)) - .throwOnError(); + for (LifeCycleHookBinding.Operation op : ALL_OPERATIONS) { + notifySubscribers(op, LifeCycleHookBinding.TransactionPhase.POSTCOMMIT); + } } /** @@ -444,9 +382,7 @@ public void runQueuedPostCommitTriggers() { * @param crudAction CRUD action */ protected void publishLifecycleEvent(PersistentResource resource, LifeCycleHookBinding.Operation crudAction) { - lifecycleEvents.onNext( - new CRUDEvent(crudAction, resource, PersistentResource.CLASS_NO_FIELD, Optional.empty()) - ); + publishLifecycleEvent(new CRUDEvent(crudAction, resource, PersistentResource.CLASS_NO_FIELD, Optional.empty())); } /** @@ -461,9 +397,23 @@ protected void publishLifecycleEvent(PersistentResource resource, String fieldName, LifeCycleHookBinding.Operation crudAction, Optional changeSpec) { - lifecycleEvents.onNext( - new CRUDEvent(crudAction, resource, fieldName, changeSpec) - ); + publishLifecycleEvent(new CRUDEvent(crudAction, resource, fieldName, changeSpec)); + } + + protected void publishLifecycleEvent(CRUDEvent event) { + if (! eventQueue.contains(event)) { + if (event.getEventType().equals(LifeCycleHookBinding.Operation.DELETE) + || event.getEventType().equals(LifeCycleHookBinding.Operation.UPDATE)) { + + LifecycleHookInvoker invoker = new LifecycleHookInvoker(dictionary, + event.getEventType(), + LifeCycleHookBinding.TransactionPhase.PRESECURITY); + + invoker.onNext(event); + } + + eventQueue.add(event); + } } public void saveOrCreateObjects() { @@ -511,27 +461,6 @@ private String getInheritanceKey(String subClass, String superClass) { return subClass + "!" + superClass; } - private void registerPreSecurityObservers() { - - this.distinctLifecycleEvents - .filter(CRUDEvent::isReadEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.READ, - LifeCycleHookBinding.TransactionPhase.PRESECURITY, true)); - - this.distinctLifecycleEvents - .filter(CRUDEvent::isUpdateEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.UPDATE, - LifeCycleHookBinding.TransactionPhase.PRESECURITY, true)); - - this.distinctLifecycleEvents - .filter(CRUDEvent::isDeleteEvent) - .subscribeWith(new LifecycleHookInvoker(dictionary, - LifeCycleHookBinding.Operation.DELETE, - LifeCycleHookBinding.TransactionPhase.PRESECURITY, true)); - } - @Override public String getRequestHeaderByName(String headerName) { if (this.requestHeaders.get(headerName) == null) { @@ -539,4 +468,19 @@ public String getRequestHeaderByName(String headerName) { } return this.requestHeaders.get(headerName).get(0); } + + @Override + public void setMetadataField(String property, Object value) { + metadata.put(property, value); + } + + @Override + public Optional getMetadataField(String property) { + return Optional.ofNullable(metadata.getOrDefault(property, null)); + } + + @Override + public Set getMetadataFields() { + return metadata.keySet(); + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/TransactionRegistry.java b/elide-core/src/main/java/com/yahoo/elide/core/TransactionRegistry.java index 291bb477a4..ab279214e7 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/TransactionRegistry.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/TransactionRegistry.java @@ -8,15 +8,16 @@ import com.yahoo.elide.core.datastore.DataStoreTransaction; import lombok.Getter; -import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + /** * Transaction Registry class. */ @Getter public class TransactionRegistry { - private Map transactionMap = new HashMap<>(); + private Map transactionMap = new ConcurrentHashMap<>(); public Map getRunningTransactions() { return transactionMap; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterable.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterable.java new file mode 100644 index 0000000000..88c647dfd3 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterable.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.datastore; + +import java.util.Iterator; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.function.Consumer; + +/** + * Returns data loaded from a DataStore. Wraps an iterable but also communicates to Elide + * if the framework needs to filter, sort, or paginate the iterable in memory before returning to the client. + * @param The type being iterated over. + */ +public interface DataStoreIterable extends Iterable { + + /** + * Returns the underlying iterable. + * @return The underlying iterable. + */ + Iterable getWrappedIterable(); + + @Override + default Iterator iterator() { + return getWrappedIterable().iterator(); + } + + @Override + default void forEach(Consumer action) { + getWrappedIterable().forEach(action); + } + + @Override + default Spliterator spliterator() { + return Spliterators.spliteratorUnknownSize(iterator(), 0); + } + + /** + * Whether the iterable should be filtered in memory. + * @return true if the iterable needs sorting in memory. false otherwise. + */ + default boolean needsInMemoryFilter() { + return false; + } + + /** + * Whether the iterable should be sorted in memory. + * @return true if the iterable needs sorting in memory. false otherwise. + */ + default boolean needsInMemorySort() { + return false; + } + + /** + * Whether the iterable should be paginated in memory. + * @return true if the iterable needs pagination in memory. false otherwise. + */ + default boolean needsInMemoryPagination() { + return false; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterableBuilder.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterableBuilder.java new file mode 100644 index 0000000000..ba12743df0 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreIterableBuilder.java @@ -0,0 +1,109 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.datastore; + +import java.util.ArrayList; + +/** + * Constructs DataStoreIterables. + * @param + */ +public class DataStoreIterableBuilder { + + private boolean filterInMemory = false; + private boolean sortInMemory = false; + private boolean paginateInMemory = false; + private final Iterable wrapped; + + /** + * Constructor. + */ + public DataStoreIterableBuilder() { + this.wrapped = new ArrayList<>(); + } + + /** + * Constructor. + * @param wrapped Required iterable to wrap. + */ + public DataStoreIterableBuilder(Iterable wrapped) { + if (wrapped == null) { + this.wrapped = new ArrayList<>(); + } else { + this.wrapped = wrapped; + } + } + + /** + * Filter the iterable in memory. + * @param filterInMemory true to filter in memory. + * @return the builder. + */ + public DataStoreIterableBuilder filterInMemory(boolean filterInMemory) { + this.filterInMemory = filterInMemory; + return this; + } + + /** + * Sorts the iterable in memory. + * @param sortInMemory true to sort in memory. + * @return the builder. + */ + public DataStoreIterableBuilder sortInMemory(boolean sortInMemory) { + this.sortInMemory = sortInMemory; + return this; + } + + /** + * Paginates the iterable in memory. + * @param paginateInMemory true to paginate in memory. + * @return the builder. + */ + public DataStoreIterableBuilder paginateInMemory(boolean paginateInMemory) { + this.paginateInMemory = paginateInMemory; + return this; + } + + /** + * Filter, sort, and paginate in memory. + * @return the builder. + */ + public DataStoreIterableBuilder allInMemory() { + this.filterInMemory = true; + this.sortInMemory = true; + this.paginateInMemory = true; + return this; + } + + /** + * Constructs the DataStoreIterable. + * @return the new iterable. + */ + public DataStoreIterable build() { + return new DataStoreIterable() { + @Override + public Iterable getWrappedIterable() { + return wrapped; + } + + @Override + public boolean needsInMemoryFilter() { + return filterInMemory; + } + + @Override + public boolean needsInMemorySort() { + return sortInMemory; + } + + @Override + public boolean needsInMemoryPagination() { + return paginateInMemory; + } + }; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreTransaction.java index b88dd0c3dc..22c5da62d3 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreTransaction.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/DataStoreTransaction.java @@ -23,21 +23,11 @@ import java.io.Closeable; import java.io.Serializable; import java.util.Iterator; -import java.util.Optional; import java.util.Set; /** * Wraps the Database Transaction type. */ public interface DataStoreTransaction extends Closeable { - - /** - * The extent to which the transaction supports a particular feature. - */ - public enum FeatureSupport { - FULL, - PARTIAL, - NONE - } /** * Save the updated object. * @@ -170,12 +160,12 @@ default T loadObject(EntityProjection entityProjection, * @param - The model type being loaded. * @return a collection of the loaded objects */ - Iterable loadObjects( + DataStoreIterable loadObjects( EntityProjection entityProjection, RequestScope scope); /** - * Retrieve a relation from an object. + * Retrieve a to-many relation from an object. * * @param relationTx - The datastore that governs objects of the relationhip's type. * @param entity - The object which owns the relationship. @@ -185,7 +175,28 @@ Iterable loadObjects( * @param - The model type of the relationship. * @return the object in the relation */ - default R getRelation( + default DataStoreIterable getToManyRelation( + DataStoreTransaction relationTx, + T entity, + Relationship relationship, + RequestScope scope) { + + return new DataStoreIterableBuilder( + (Iterable) PersistentResource.getValue(entity, relationship.getName(), scope)).allInMemory().build(); + } + + /** + * Retrieve a to-one relation from an object. + * + * @param relationTx - The datastore that governs objects of the relationhip's type. + * @param entity - The object which owns the relationship. + * @param relationship - the relationship to fetch. + * @param scope - contains request level metadata. + * @param - The model type which owns the relationship. + * @param - The model type of the relationship. + * @return the object in the relation + */ + default R getToOneRelation( DataStoreTransaction relationTx, T entity, Relationship relationship, @@ -270,48 +281,6 @@ default void setAttribute(T entity, RequestScope scope) { } - /** - * Whether or not the transaction can filter the provided class with the provided expression. - * @param scope The request scope - * @param projection The projection being loaded - * @param parent Are we filtering a root collection or a relationship - * @param - The model type of the parent model (if a relationship is being filtered). - * @return FULL, PARTIAL, or NONE - */ - default FeatureSupport supportsFiltering(RequestScope scope, - Optional parent, - EntityProjection projection) { - return FeatureSupport.FULL; - } - - /** - * Whether or not the transaction can sort the provided class. - * @param scope The request scope - * @param projection The projection being loaded - * @param parent Are we filtering a root collection or a relationship - * @param - The model type of the parent model (if a relationship is being sorted). - * @return true if sorting is possible - */ - default boolean supportsSorting(RequestScope scope, - Optional parent, - EntityProjection projection) { - return true; - } - - /** - * Whether or not the transaction can paginate the provided class. - * @param scope The request scope - * @param projection The projection being loaded - * @param parent Are we filtering a root collection or a relationship - * @param - The model type of the parent model (if a relationship is being paginated). - * @return true if pagination is possible - */ - default boolean supportsPagination(RequestScope scope, - Optional parent, - EntityProjection projection) { - return true; - } - /** * Cancel running transaction. * Implementation must be thread-safe. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/FilteredIterator.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/FilteredIterator.java new file mode 100644 index 0000000000..d8d479e95b --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/FilteredIterator.java @@ -0,0 +1,77 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.datastore.inmemory; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.expression.InMemoryFilterExecutor; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.function.Predicate; + +/** + * An iterator which filters another iterator by an Elide filter expression. + * @param The type being iterated over. + */ +public class FilteredIterator implements Iterator { + + private Iterator wrapped; + private Predicate predicate; + + private T next; + + /** + * Constructor. + * @param filterExpression The filter expression to filter on. + * @param scope Request scope. + * @param wrapped The wrapped iterator. + */ + public FilteredIterator(FilterExpression filterExpression, RequestScope scope, Iterator wrapped) { + this.wrapped = wrapped; + InMemoryFilterExecutor executor = new InMemoryFilterExecutor(scope); + + predicate = filterExpression.accept(executor); + } + + @Override + public boolean hasNext() { + try { + next = next(); + } catch (NoSuchElementException e) { + return false; + } + + return true; + } + + @Override + public T next() { + if (next != null) { + T result = next; + next = null; + return result; + } + + while (next == null && wrapped.hasNext()) { + try { + next = wrapped.next(); + } catch (NoSuchElementException e) { + next = null; + } + if (next == null || ! predicate.test(next)) { + next = null; + } + } + + if (next == null) { + throw new NoSuchElementException(); + } + + return next; + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java index a3520b5d00..45eee754e4 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/HashMapStoreTransaction.java @@ -6,6 +6,8 @@ package com.yahoo.elide.core.datastore.inmemory; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.TransactionException; @@ -19,7 +21,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; import javax.persistence.GeneratedValue; @@ -125,19 +126,20 @@ public void setId(Object value, String id) { } @Override - public Object getRelation(DataStoreTransaction relationTx, - Object entity, - Relationship relationship, - RequestScope scope) { - return dictionary.getValue(entity, relationship.getName(), scope); + public DataStoreIterable getToManyRelation(DataStoreTransaction relationTx, + Object entity, + Relationship relationship, + RequestScope scope) { + return new DataStoreIterableBuilder( + (Iterable) dictionary.getValue(entity, relationship.getName(), scope)).allInMemory().build(); } @Override - public Iterable loadObjects(EntityProjection projection, - RequestScope scope) { + public DataStoreIterable loadObjects(EntityProjection projection, + RequestScope scope) { synchronized (dataStore) { Map data = dataStore.get(projection.getType()); - return data.values(); + return new DataStoreIterableBuilder<>(data.values()).allInMemory().build(); } } @@ -163,21 +165,6 @@ public void close() throws IOException { operations.clear(); } - @Override - public FeatureSupport supportsFiltering(RequestScope scope, Optional parent, EntityProjection projection) { - return FeatureSupport.NONE; - } - - @Override - public boolean supportsSorting(RequestScope scope, Optional parent, EntityProjection projection) { - return false; - } - - @Override - public boolean supportsPagination(RequestScope scope, Optional parent, EntityProjection projection) { - return false; - } - private boolean containsObject(Object obj) { return containsObject(EntityDictionary.getType(obj), obj); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java index d3358737f7..4cf1da1aff 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransaction.java @@ -9,11 +9,12 @@ import com.yahoo.elide.core.Path; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.filter.expression.FilterPredicatePushdownExtractor; import com.yahoo.elide.core.filter.expression.InMemoryExecutionVerifier; -import com.yahoo.elide.core.filter.expression.InMemoryFilterExecutor; import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Pagination; @@ -27,11 +28,11 @@ import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -64,24 +65,23 @@ public class InMemoryStoreTransaction implements DataStoreTransaction { */ @FunctionalInterface private interface DataFetcher { - Object fetch(Optional filterExpression, - Optional sorting, - Optional pagination, - RequestScope scope); + DataStoreIterable fetch(Optional filterExpression, + Optional sorting, + Optional pagination, + RequestScope scope); } - public InMemoryStoreTransaction(DataStoreTransaction tx) { this.tx = tx; } @Override - public Object getRelation(DataStoreTransaction relationTx, - Object entity, - Relationship relationship, - RequestScope scope) { + public DataStoreIterable getToManyRelation(DataStoreTransaction relationTx, + Object entity, + Relationship relationship, + RequestScope scope) { DataFetcher fetcher = (filterExpression, sorting, pagination, requestScope) -> - tx.getRelation(relationTx, entity, relationship.copyOf() + tx.getToManyRelation(relationTx, entity, relationship.copyOf() .projection(relationship.getProjection().copyOf() .filterExpression(filterExpression.orElse(null)) .sorting(sorting.orElse(null)) @@ -95,23 +95,22 @@ public Object getRelation(DataStoreTransaction relationTx, * It must be done in memory by Elide as some newly created entities have not yet been persisted. */ boolean filterInMemory = scope.getNewPersistentResources().size() > 0; - return fetchData(fetcher, Optional.of(entity), relationship.getProjection(), filterInMemory, scope); + return fetchData(fetcher, relationship.getProjection(), filterInMemory, scope); } @Override public Object loadObject(EntityProjection projection, Serializable id, RequestScope scope) { - - if (projection.getFilterExpression() == null - || tx.supportsFiltering(scope, Optional.empty(), projection) == FeatureSupport.FULL) { + if (projection.getFilterExpression() == null) { return tx.loadObject(projection, id, scope); } + return DataStoreTransaction.super.loadObject(projection, id, scope); } @Override - public Iterable loadObjects(EntityProjection projection, + public DataStoreIterable loadObjects(EntityProjection projection, RequestScope scope) { DataFetcher fetcher = (filterExpression, sorting, pagination, requestScope) -> @@ -121,7 +120,7 @@ public Iterable loadObjects(EntityProjection projection, .sorting(sorting.orElse(null)) .build(), requestScope); - return (Iterable) fetchData(fetcher, Optional.empty(), projection, false, scope); + return fetchData(fetcher, projection, false, scope); } @Override @@ -144,6 +143,15 @@ public T createNewObject(Type entityClass, RequestScope scope) { return tx.createNewObject(entityClass, scope); } + @Override + public R getToOneRelation( + DataStoreTransaction relationTx, + T entity, Relationship relationship, + RequestScope scope + ) { + return tx.getToOneRelation(relationTx, entity, relationship, scope); + } + @Override public void close() throws IOException { tx.close(); @@ -193,85 +201,104 @@ public void createObject(Object entity, RequestScope scope) { tx.createObject(entity, scope); } - private Iterable filterLoadedData(Iterable loadedRecords, + private DataStoreIterable filterLoadedData(DataStoreIterable loadedRecords, Optional filterExpression, RequestScope scope) { + if (! filterExpression.isPresent()) { return loadedRecords; } - Predicate predicate = filterExpression.get().accept(new InMemoryFilterExecutor(scope)); + return new DataStoreIterable<>() { + @Override + public Iterable getWrappedIterable() { + return loadedRecords; + } + + @Override + public Iterator iterator() { + return new FilteredIterator<>(filterExpression.get(), scope, loadedRecords.iterator()); + } - return StreamSupport.stream(loadedRecords.spliterator(), false) - .filter(predicate::test) - .collect(Collectors.toList()); - } + @Override + public boolean needsInMemoryFilter() { + return true; + } + + @Override + public boolean needsInMemorySort() { + return true; + } - private Object fetchData(DataFetcher fetcher, - Optional parent, - EntityProjection projection, - boolean filterInMemory, - RequestScope scope) { + @Override + public boolean needsInMemoryPagination() { + return true; + } + }; + } + private DataStoreIterable fetchData( + DataFetcher fetcher, + EntityProjection projection, + boolean filterInMemory, + RequestScope scope + ) { Optional filterExpression = Optional.ofNullable(projection.getFilterExpression()); Pair, Optional> expressionSplit = splitFilterExpression( - scope, parent, projection, filterInMemory); + scope, projection, filterInMemory); Optional dataStoreFilter = expressionSplit.getLeft(); Optional inMemoryFilter = expressionSplit.getRight(); - Pair, Optional> sortSplit = splitSorting(scope, parent, - projection, inMemoryFilter.isPresent()); - - Optional dataStoreSort = sortSplit.getLeft(); - Optional inMemorySort = sortSplit.getRight(); + Optional dataStoreSorting = getDataStoreSorting(scope, projection, filterInMemory); - Pair, Optional> paginationSplit = splitPagination(scope, parent, - projection, inMemoryFilter.isPresent(), inMemorySort.isPresent()); + boolean sortingInMemory = dataStoreSorting.isEmpty() && projection.getSorting() != null; - Optional dataStorePagination = paginationSplit.getLeft(); - Optional inMemoryPagination = paginationSplit.getRight(); + Optional dataStorePagination = inMemoryFilter.isPresent() || sortingInMemory + ? Optional.empty() : Optional.ofNullable(projection.getPagination()); - Object result = fetcher.fetch(dataStoreFilter, dataStoreSort, dataStorePagination, scope); + DataStoreIterable loadedRecords = + fetcher.fetch(dataStoreFilter, dataStoreSorting, dataStorePagination, scope); - if (! (result instanceof Iterable)) { - return result; + if (loadedRecords == null) { + return new DataStoreIterableBuilder().build(); } - Iterable loadedRecords = (Iterable) result; - - if (inMemoryFilter.isPresent()) { + if (inMemoryFilter.isPresent() || (loadedRecords.needsInMemoryFilter() + && projection.getFilterExpression() != null)) { loadedRecords = filterLoadedData(loadedRecords, filterExpression, scope); } - return sortAndPaginateLoadedData( loadedRecords, - inMemorySort, - inMemoryPagination, + sortingInMemory, + projection.getSorting(), + projection.getPagination(), scope); } + private DataStoreIterable sortAndPaginateLoadedData( + DataStoreIterable loadedRecords, + boolean sortingInMemory, + Sorting sorting, + Pagination pagination, + RequestScope scope + ) { + + Map sortRules = sorting == null ? new HashMap<>() : sorting.getSortingPaths(); + + boolean mustSortInMemory = ! sortRules.isEmpty() + && (sortingInMemory || loadedRecords.needsInMemorySort()); - private Iterable sortAndPaginateLoadedData(Iterable loadedRecords, - Optional sorting, - Optional pagination, - RequestScope scope) { + boolean mustPaginateInMemory = pagination != null + && (mustSortInMemory || loadedRecords.needsInMemoryPagination()); //Try to skip the data copy if possible - if (! sorting.isPresent() && ! pagination.isPresent()) { + if (! mustSortInMemory && ! mustPaginateInMemory) { return loadedRecords; } - Map sortRules = sorting - .map(Sorting::getSortingPaths) - .orElseGet(HashMap::new); - - // No sorting required for this type & no pagination. - if (sortRules.isEmpty() && ! pagination.isPresent()) { - return loadedRecords; - } //We need an in memory copy to sort or paginate. List results = StreamSupport.stream(loadedRecords.spliterator(), false).collect(Collectors.toList()); @@ -279,11 +306,11 @@ private Iterable sortAndPaginateLoadedData(Iterable loadedRecord results = sortInMemory(results, sortRules, scope); } - if (pagination.isPresent()) { - results = paginateInMemory(results, pagination.get()); + if (pagination != null) { + results = paginateInMemory(results, pagination); } - return results; + return new DataStoreIterableBuilder(results).build(); } private List paginateInMemory(List records, Pagination pagination) { @@ -345,18 +372,51 @@ private Comparator getComparator(Path path, Sorting.SortOrder order, Req } /** - * Splits a filter expression into two components: + * Returns the sorting (if any) that should be pushed to the datastore. + * @param scope The request context + * @param projection The projection being loaded. + * @param filterInMemory Whether or not the transaction requires in memory filtering. + * @return An optional sorting. + */ + private Optional getDataStoreSorting( + RequestScope scope, + EntityProjection projection, + boolean filterInMemory + ) { + Sorting sorting = projection.getSorting(); + if (filterInMemory) { + return Optional.empty(); + } + Map sortRules = sorting == null ? new HashMap<>() : sorting.getSortingPaths(); + + boolean sortingOnComputedAttribute = false; + for (Path path: sortRules.keySet()) { + if (path.isComputed(scope.getDictionary())) { + Type pathType = path.getPathElements().get(0).getType(); + if (projection.getType().equals(pathType)) { + sortingOnComputedAttribute = true; + break; + } + } + } + if (sortingOnComputedAttribute) { + return Optional.empty(); + } else { + return Optional.ofNullable(sorting); + } + } + + /** + * Splits a filter expression into two components. They are: * - a component that should be pushed down to the data store * - a component that should be executed in memory * @param scope The request context - * @param parent If this is a relationship load, the parent object. Otherwise not set. * @param projection The projection being loaded. * @param filterInMemory Whether or not the transaction requires in memory filtering. * @return A pair of filter expressions (data store expression, in memory expression) */ private Pair, Optional> splitFilterExpression( RequestScope scope, - Optional parent, EntityProjection projection, boolean filterInMemory ) { @@ -368,11 +428,7 @@ private Pair, Optional> splitFilter boolean transactionNeedsInMemoryFiltering = filterInMemory; if (filterExpression.isPresent()) { - FeatureSupport filterSupport = tx.supportsFiltering(scope, parent, projection); - - boolean storeNeedsInMemoryFiltering = filterSupport != FeatureSupport.FULL; - - if (transactionNeedsInMemoryFiltering || filterSupport == FeatureSupport.NONE) { + if (transactionNeedsInMemoryFiltering) { inStoreFilterExpression = Optional.empty(); } else { inStoreFilterExpression = Optional.ofNullable( @@ -383,7 +439,7 @@ private Pair, Optional> splitFilter boolean expressionNeedsInMemoryFiltering = InMemoryExecutionVerifier.shouldExecuteInMemory( scope.getDictionary(), filterExpression.get()); - if (transactionNeedsInMemoryFiltering || storeNeedsInMemoryFiltering || expressionNeedsInMemoryFiltering) { + if (transactionNeedsInMemoryFiltering || expressionNeedsInMemoryFiltering) { inMemoryFilterExpression = filterExpression; } } @@ -391,68 +447,6 @@ private Pair, Optional> splitFilter return Pair.of(inStoreFilterExpression, inMemoryFilterExpression); } - /** - * Splits a sorting object into two components: - * - a component that should be pushed down to the data store - * - a component that should be executed in memory - * @param scope The request context - * @param parent If this is a relationship load, the parent object. Otherwise not set. - * @param projection The projection being loaded. - * @param filteredInMemory Whether or not filtering was performed in memory - * @return A pair of sorting objects (data store sort, in memory sort) - */ - private Pair, Optional> splitSorting( - RequestScope scope, - Optional parent, - EntityProjection projection, - boolean filteredInMemory - ) { - Optional sorting = Optional.ofNullable(projection.getSorting()); - - if (sorting.isPresent() && (! tx.supportsSorting(scope, parent, projection) || filteredInMemory)) { - return Pair.of(Optional.empty(), sorting); - } - return Pair.of(sorting, Optional.empty()); - } - - /** - * Splits a pagination object into two components: - * - a component that should be pushed down to the data store - * - a component that should be executed in memory - * @param scope The request context - * @param parent If this is a relationship load, the parent object. Otherwise not set. - * @param projection The projection being loaded. - * @param filteredInMemory Whether or not filtering was performed in memory - * @param sortedInMemory Whether or not sorting was performed in memory - * @return A pair of pagination objects (data store pagination, in memory pagination) - */ - private Pair, Optional> splitPagination( - RequestScope scope, - Optional parent, - EntityProjection projection, - boolean filteredInMemory, - boolean sortedInMemory - ) { - - Optional pagination = Optional.ofNullable(projection.getPagination()); - - if (!tx.supportsPagination(scope, parent, projection) - || filteredInMemory - || sortedInMemory) { - return Pair.of(Optional.empty(), pagination); - - /* - * For default pagination, we let the store do its work, but we also let the store ignore pagination - * by also performing in memory. This allows the ORM the opportunity to manage its own SQL query generation - * to avoid N+1. - */ - } else if (pagination.isPresent() && pagination.get().isDefaultInstance()) { - return Pair.of(pagination, pagination); - } else { - return Pair.of(pagination, Optional.empty()); - } - } - @Override public void cancel(RequestScope scope) { tx.cancel(scope); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapper.java b/elide-core/src/main/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapper.java index 220757e617..dd9781ef8a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapper.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapper.java @@ -7,6 +7,7 @@ package com.yahoo.elide.core.datastore.wrapped; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.request.EntityProjection; @@ -16,7 +17,6 @@ import java.io.IOException; import java.io.Serializable; -import java.util.Optional; import java.util.Set; /** @@ -39,9 +39,15 @@ public T loadObject(EntityProjection projection, Serializable id, } @Override - public R getRelation(DataStoreTransaction relationTx, T entity, - Relationship relationship, RequestScope scope) { - return tx.getRelation(relationTx, entity, relationship, scope); + public DataStoreIterable getToManyRelation(DataStoreTransaction relationTx, T entity, + Relationship relationship, RequestScope scope) { + return tx.getToManyRelation(relationTx, entity, relationship, scope); + } + + @Override + public R getToOneRelation(DataStoreTransaction relationTx, T entity, + Relationship relationship, RequestScope scope) { + return tx.getToOneRelation(relationTx, entity, relationship, scope); } @Override @@ -68,21 +74,6 @@ public void setAttribute(T entity, Attribute attribute, RequestScope scope) tx.setAttribute(entity, attribute, scope); } - @Override - public FeatureSupport supportsFiltering(RequestScope scope, Optional parent, EntityProjection projection) { - return tx.supportsFiltering(scope, parent, projection); - } - - @Override - public boolean supportsSorting(RequestScope scope, Optional parent, EntityProjection projection) { - return tx.supportsSorting(scope, parent, projection); - } - - @Override - public boolean supportsPagination(RequestScope scope, Optional parent, EntityProjection projection) { - return tx.supportsPagination(scope, parent, projection); - } - @Override public void save(T o, RequestScope requestScope) { tx.save(o, requestScope); @@ -109,7 +100,7 @@ public void createObject(Object o, RequestScope requestScope) { } @Override - public Iterable loadObjects(EntityProjection projection, RequestScope scope) { + public DataStoreIterable loadObjects(EntityProjection projection, RequestScope scope) { return tx.loadObjects(projection, scope); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/ArgumentType.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/ArgumentType.java index 29ae71c304..7024a6a66d 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/ArgumentType.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/ArgumentType.java @@ -6,6 +6,7 @@ package com.yahoo.elide.core.dictionary; import com.yahoo.elide.core.type.Type; +import lombok.Builder; import lombok.Value; /** @@ -21,6 +22,7 @@ public ArgumentType(String name, Type type) { this(name, type, null); } + @Builder public ArgumentType(String name, Type type, Object defaultValue) { this.name = name; this.type = type; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityBinding.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityBinding.java index d3524d38b4..863e4fbe97 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityBinding.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityBinding.java @@ -67,6 +67,8 @@ * @see com.yahoo.elide.annotation.Include#name */ public class EntityBinding { + public static final List> ID_ANNOTATIONS = List.of(Id.class, EmbeddedId.class); + private static final List> RELATIONSHIP_TYPES = Arrays.asList(ManyToMany.class, ManyToOne.class, OneToMany.class, OneToOne.class, ToOne.class, ToMany.class); @@ -116,7 +118,6 @@ public class EntityBinding { public final ConcurrentHashMap requestScopeableMethods = new ConcurrentHashMap<>(); public final ConcurrentHashMap> attributeArguments = new ConcurrentHashMap<>(); public final ConcurrentHashMap entityArguments = new ConcurrentHashMap<>(); - public final ConcurrentHashMap annotations = new ConcurrentHashMap<>(); public static final EntityBinding EMPTY_BINDING = new EntityBinding(); @@ -149,7 +150,7 @@ private EntityBinding() { public EntityBinding(Injector injector, Type cls, String type) { - this(injector, cls, type, NO_VERSION, new HashSet<>()); + this(injector, cls, type, NO_VERSION, unused -> false); } /** @@ -159,14 +160,14 @@ public EntityBinding(Injector injector, * @param cls Entity class * @param type Declared Elide type name * @param apiVersion API version - * @param hiddenAnnotations Annotations for hiding a field in API + * @param isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. */ public EntityBinding(Injector injector, Type cls, String type, String apiVersion, - Set> hiddenAnnotations) { - this(injector, cls, type, apiVersion, true, hiddenAnnotations); + Predicate isFieldHidden) { + this(injector, cls, type, apiVersion, true, isFieldHidden); } /** @@ -177,14 +178,14 @@ public EntityBinding(Injector injector, * @param type Declared Elide type name * @param apiVersion API version * @param isElideModel Whether or not this type is an Elide model or not. - * @param hiddenAnnotations Annotations for hiding a field in API + * @param isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. */ public EntityBinding(Injector injector, Type cls, String type, String apiVersion, boolean isElideModel, - Set> hiddenAnnotations) { + Predicate isFieldHidden) { this.isElideModel = isElideModel; this.injector = injector; entityClass = cls; @@ -223,7 +224,7 @@ public EntityBinding(Injector injector, fieldOrMethodList.addAll(getInstanceMembers(cls.getMethods())); } - bindEntityFields(cls, type, fieldOrMethodList, hiddenAnnotations); + bindEntityFields(cls, type, fieldOrMethodList, isFieldHidden); bindTriggerIfPresent(); apiAttributes = dequeToList(attributesDeque); @@ -289,11 +290,11 @@ public List getAllMethods() { * @param cls Class type to bind fields * @param type JSON API type identifier * @param fieldOrMethodList List of fields and methods on entity - * @param hiddenAnnotations Annotations for hiding a field in API + * @param isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. */ private void bindEntityFields(Type cls, String type, Collection fieldOrMethodList, - Set> hiddenAnnotations) { + Predicate isFieldHidden) { for (AccessibleObject fieldOrMethod : fieldOrMethodList) { bindTriggerIfPresent(fieldOrMethod); @@ -317,7 +318,7 @@ private void bindEntityFields(Type cls, String type, } bindAttrOrRelation( fieldOrMethod, - hiddenAnnotations.stream().anyMatch(fieldOrMethod::isAnnotationPresent)); + isFieldHidden.test(fieldOrMethod)); } } } @@ -519,9 +520,11 @@ public static boolean isComputedMethod(Method method) { * @param fieldOrMethod field or method * @return field type */ - public static Type getFieldType(Type parentClass, - AccessibleObject fieldOrMethod) { - return getFieldType(parentClass, fieldOrMethod, Optional.empty()); + public static Type getFieldType(Type parentClass, AccessibleObject fieldOrMethod) { + if (fieldOrMethod instanceof Field) { + return ((Field) fieldOrMethod).getType(); + } + return ((Method) fieldOrMethod).getReturnType(); } /** @@ -733,6 +736,15 @@ public Set> getAttributes() { .collect(Collectors.toSet()); } + /** + * Returns a list of fields filtered by a given predicate. + * @param filter The filter predicate. + * @return All fields that satisfy the predicate. + */ + public Set getAllFields(Predicate filter) { + return fieldsToValues.values().stream().filter(filter).collect(Collectors.toSet()); + } + /** * Returns the Collection of all attributes of an Entity. * @return A Set of ArgumentType for the given entity. @@ -741,7 +753,7 @@ public Set getEntityArguments() { return new HashSet<>(entityArguments.values()); } - private static boolean isIdField(AccessibleObject field) { + public static boolean isIdField(AccessibleObject field) { return (field.isAnnotationPresent(Id.class) || field.isAnnotationPresent(EmbeddedId.class)); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java index 153995b879..a855d02afd 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/dictionary/EntityDictionary.java @@ -7,6 +7,8 @@ package com.yahoo.elide.core.dictionary; import static com.yahoo.elide.core.dictionary.EntityBinding.EMPTY_BINDING; +import static com.yahoo.elide.core.security.checks.prefab.Role.ALL_ROLE; +import static com.yahoo.elide.core.security.checks.prefab.Role.NONE_ROLE; import static com.yahoo.elide.core.type.ClassType.COLLECTION_TYPE; import static com.yahoo.elide.core.type.ClassType.MAP_TYPE; import com.yahoo.elide.annotation.ApiVersion; @@ -23,9 +25,6 @@ import com.yahoo.elide.annotation.OnDeletePostCommit; import com.yahoo.elide.annotation.OnDeletePreCommit; import com.yahoo.elide.annotation.OnDeletePreSecurity; -import com.yahoo.elide.annotation.OnReadPostCommit; -import com.yahoo.elide.annotation.OnReadPreCommit; -import com.yahoo.elide.annotation.OnReadPreSecurity; import com.yahoo.elide.annotation.OnUpdatePostCommit; import com.yahoo.elide.annotation.OnUpdatePreCommit; import com.yahoo.elide.annotation.OnUpdatePreSecurity; @@ -88,6 +87,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import javax.persistence.AccessType; import javax.persistence.CascadeType; @@ -173,9 +173,9 @@ private void initializeChecks() { UserCheck none = new Role.NONE(); addRoleCheck("Prefab.Role.All", all); - addRoleCheck("ALL", all); + addRoleCheck(ALL_ROLE, all); addRoleCheck("Prefab.Role.None", none); - addRoleCheck("NONE", none); + addRoleCheck(NONE_ROLE, none); addPrefabCheck("Prefab.Collections.AppendOnly", AppendOnly.class); addPrefabCheck("Prefab.Collections.RemoveOnly", RemoveOnly.class); @@ -664,12 +664,12 @@ public boolean isMethodRequestScopeable(Type entityClass, Method method) { } /** - * Get a list of all fields including both relationships and attributes. + * Get a list of all fields including both relationships and attributes (but excluding hidden fields). * * @param entityClass entity name - * @return List of all fields. + * @return List of all exposed fields. */ - public List getAllFields(Type entityClass) { + public List getAllExposedFields(Type entityClass) { List fields = new ArrayList<>(); List attrs = getAttributes(entityClass); @@ -692,8 +692,8 @@ public List getAllFields(Type entityClass) { * @param entity entity * @return List of all fields. */ - public List getAllFields(Object entity) { - return getAllFields(getType(entity)); + public List getAllExposedFields(Object entity) { + return getAllExposedFields(getType(entity)); } /** @@ -972,33 +972,33 @@ public void bindEntity(Class cls) { * @param cls Entity bean class */ public void bindEntity(Type cls) { - bindEntity(cls, new HashSet<>()); + bindEntity(cls, unused -> false); } /** * Add given Entity bean to dictionary. * * @param cls Entity bean class - * @param hiddenAnnotations Annotations for hiding a field in API + * @param isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. */ - public void bindEntity(Class cls, Set> hiddenAnnotations) { - bindEntity(ClassType.of(cls), hiddenAnnotations); + public void bindEntity(Class cls, Predicate isFieldHidden) { + bindEntity(ClassType.of(cls), isFieldHidden); } /** * Add given Entity bean to dictionary. * * @param cls Entity bean class - * @param hiddenAnnotations Annotations for hiding a field in API + * @param isFieldHidden Function which determines if a given field should be in the dictionary but not exposed. */ - public void bindEntity(Type cls, Set> hiddenAnnotations) { - if (entitiesToExclude.contains(cls)) { + public void bindEntity(Type cls, Predicate isFieldHidden) { + Type declaredClass = lookupIncludeClass(cls); + + if (entitiesToExclude.contains(declaredClass)) { //Exclude Entity return; } - Type declaredClass = lookupIncludeClass(cls); - if (declaredClass == null) { log.trace("Missing include or excluded class {}", cls.getName()); return; @@ -1014,7 +1014,7 @@ public void bindEntity(Type cls, Set> hiddenAnnot bindJsonApiToEntity.put(Pair.of(type, version), declaredClass); apiVersions.add(version); - EntityBinding binding = new EntityBinding(injector, declaredClass, type, version, hiddenAnnotations); + EntityBinding binding = new EntityBinding(injector, declaredClass, type, version, isFieldHidden); entityBindings.put(declaredClass, binding); Include include = (Include) getFirstAnnotation(declaredClass, Arrays.asList(Include.class)); @@ -1474,7 +1474,7 @@ public AccessibleObject getAccessibleObject(Type targetClass, String fieldNam */ public Set getFieldsOfType(Type targetClass, Type targetType) { HashSet fields = new HashSet<>(); - for (String field : getAllFields(targetClass)) { + for (String field : getAllExposedFields(targetClass)) { if (getParameterizedType(targetClass, field).equals(targetType)) { fields.add(field); } @@ -1537,10 +1537,10 @@ public void addSecurityCheck(Class cls) { /** * Binds a lifecycle hook to a particular field or method in an entity. The hook will be called a - * single time per request per field READ, CREATE, or UPDATE. + * single time per request per field CREATE, or UPDATE. * @param entityClass The entity that triggers the lifecycle hook. * @param fieldOrMethodName The name of the field or method. - * @param operation CREATE, READ, or UPDATE + * @param operation CREATE, or UPDATE * @param phase PRESECURITY, PRECOMMIT, or POSTCOMMIT * @param hook The callback to invoke. */ @@ -1554,10 +1554,10 @@ public void bindTrigger(Class entityClass, /** * Binds a lifecycle hook to a particular field or method in an entity. The hook will be called a - * single time per request per field READ, CREATE, or UPDATE. + * single time per request per field CREATE, or UPDATE. * @param entityClass The entity that triggers the lifecycle hook. * @param fieldOrMethodName The name of the field or method. - * @param operation CREATE, READ, or UPDATE + * @param operation CREATE, or UPDATE * @param phase PRESECURITY, PRECOMMIT, or POSTCOMMIT * @param hook The callback to invoke. */ @@ -1573,12 +1573,12 @@ public void bindTrigger(Type entityClass, /** * Binds a lifecycle hook to a particular entity class. The hook will either be called: - * - A single time single time per request per class READ, CREATE, UPDATE, or DELETE. - * - Multiple times per request per field READ, CREATE, or UPDATE. + * - A single time single time per request per class CREATE, UPDATE, or DELETE. + * - Multiple times per request per field CREATE, or UPDATE. * * The behavior is determined by the value of the {@code allowMultipleInvocations} flag. * @param entityClass The entity that triggers the lifecycle hook. - * @param operation CREATE, READ, or UPDATE + * @param operation CREATE, or UPDATE * @param phase PRESECURITY, PRECOMMIT, or POSTCOMMIT * @param hook The callback to invoke. * @param allowMultipleInvocations Should the same life cycle hook be invoked multiple times for multiple @@ -1594,12 +1594,12 @@ public void bindTrigger(Class entityClass, /** * Binds a lifecycle hook to a particular entity class. The hook will either be called: - * - A single time single time per request per class READ, CREATE, UPDATE, or DELETE. - * - Multiple times per request per field READ, CREATE, or UPDATE. + * - A single time single time per request per class CREATE, UPDATE, or DELETE. + * - Multiple times per request per field CREATE, or UPDATE. * * The behavior is determined by the value of the {@code allowMultipleInvocations} flag. * @param entityClass The entity that triggers the lifecycle hook. - * @param operation CREATE, READ, or UPDATE + * @param operation CREATE, or UPDATE * @param phase PRESECURITY, PRECOMMIT, or POSTCOMMIT * @param hook The callback to invoke. * @param allowMultipleInvocations Should the same life cycle hook be invoked multiple times for multiple @@ -1869,7 +1869,7 @@ public boolean attributeOrRelationAnnotationExists( * @return {@code true} if the field exists in the entity */ public boolean isValidField(Type cls, String fieldName) { - return getAllFields(cls).contains(fieldName); + return getAllExposedFields(cls).contains(fieldName); } private boolean isValidParameterizedMap(Map values, Class keyType, Class valueType) { @@ -1995,7 +1995,7 @@ protected void discoverEmbeddedTypeBindings(Type elideModel) { next.getSimpleName(), binding.getApiVersion(), false, - new HashSet<>()); + (unused) -> false); entityBindings.put(next, nextBinding); @@ -2109,18 +2109,6 @@ public void bindLegacyHooks(EntityBinding binding) { bindHookMethod(binding, method, method.getAnnotation(OnUpdatePreSecurity.class).value(), TransactionPhase.PRESECURITY, Operation.UPDATE); } - if (method.isAnnotationPresent(OnReadPostCommit.class)) { - bindHookMethod(binding, method, method.getAnnotation(OnReadPostCommit.class).value(), - TransactionPhase.POSTCOMMIT, Operation.READ); - } - if (method.isAnnotationPresent(OnReadPreCommit.class)) { - bindHookMethod(binding, method, method.getAnnotation(OnReadPreCommit.class).value(), - TransactionPhase.PRECOMMIT, Operation.READ); - } - if (method.isAnnotationPresent(OnReadPreSecurity.class)) { - bindHookMethod(binding, method, method.getAnnotation(OnReadPreSecurity.class).value(), - TransactionPhase.PRESECURITY, Operation.READ); - } if (method.isAnnotationPresent(OnDeletePostCommit.class)) { bindHookMethod(binding, method, null, TransactionPhase.POSTCOMMIT, Operation.DELETE); } @@ -2146,7 +2134,7 @@ public boolean isComplexAttribute(Type clazz, String fieldName) { return false; } - Type attributeType = getParameterizedType(clazz, fieldName); + Type attributeType = getType(clazz, fieldName); return canBind(attributeType); } @@ -2198,11 +2186,25 @@ private boolean canBind(Type type) { Class clazz = type.getUnderlyingClass().get(); + boolean hasNoArgConstructor = + Arrays.stream(clazz.getConstructors()).anyMatch(constructor -> constructor.getParameterCount() == 0); + + //We don't bind primitives. if (ClassUtils.isPrimitiveOrWrapper(clazz) || clazz.equals(String.class) || clazz.isEnum() + + //We don't bind collections. || Collection.class.isAssignableFrom(clazz) || Map.class.isAssignableFrom(clazz) + + //We can't bind an attribute type if Elide can't create it... + || ! hasNoArgConstructor + + //We don't bind Elide models as attributes. + || lookupIncludeClass(type) != null + + //If there is a Serde, we assume the type is opaque to Elide.... || serdeLookup.apply(clazz) != null) { return false; } @@ -2229,14 +2231,14 @@ public EntityDictionary build() { serdeLookup = CoerceUtil::lookup; } - if (entitiesToExclude == null) { - entitiesToExclude = Collections.emptySet(); - } - if (injector == null) { injector = DEFAULT_INJECTOR; } + if (entitiesToExclude == null) { + entitiesToExclude = Collections.emptySet(); + } + return new EntityDictionary( checks, roleChecks, diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorObjects.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorObjects.java index 74856c8b30..635e59b87a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorObjects.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/ErrorObjects.java @@ -68,6 +68,9 @@ public ErrorObjectsBuilder withDetail(String detail) { } public ErrorObjectsBuilder with(String key, Object value) { + if (currentError == null) { + throw new UnsupportedOperationException("Must add an error before calling with"); + } currentError.put(key, value); return this; } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperatorNegationException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperatorNegationException.java index 2966324cbc..7306581347 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperatorNegationException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidOperatorNegationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidParameterizedAttributeException.java b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidParameterizedAttributeException.java index aee5cda8e8..7de8baefed 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidParameterizedAttributeException.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/exceptions/InvalidParameterizedAttributeException.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Oath Inc. + * Copyright 2021, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/Operator.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/Operator.java index d271e58f92..bf632fe840 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/Operator.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/Operator.java @@ -6,6 +6,8 @@ package com.yahoo.elide.core.filter; import static com.yahoo.elide.core.type.ClassType.COLLECTION_TYPE; +import static java.util.Map.entry; + import com.yahoo.elide.core.Path; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; @@ -13,7 +15,9 @@ import com.yahoo.elide.core.exceptions.InvalidOperatorNegationException; import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.coerce.CoerceUtil; + import org.apache.commons.collections4.CollectionUtils; + import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -69,6 +73,12 @@ public Predicate contextualize(Path fieldPath, List values, Reque } }, + NOT_PREFIX_CASE_INSENSITIVE("notprefixi", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return notprefix(fieldPath, values, requestScope, s -> s.toLowerCase(Locale.ENGLISH)); + } + }, PREFIX("prefix", true) { @Override public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { @@ -76,6 +86,13 @@ public Predicate contextualize(Path fieldPath, List values, Reque } }, + NOT_PREFIX("notprefix", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return notprefix(fieldPath, values, requestScope, UnaryOperator.identity()); + } + }, + POSTFIX("postfix", true) { @Override public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { @@ -83,6 +100,13 @@ public Predicate contextualize(Path fieldPath, List values, Reque } }, + NOT_POSTFIX("notpostfix", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return notpostfix(fieldPath, values, requestScope, UnaryOperator.identity()); + } + }, + POSTFIX_CASE_INSENSITIVE("postfixi", true) { @Override public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { @@ -90,6 +114,13 @@ public Predicate contextualize(Path fieldPath, List values, Reque } }, + NOT_POSTFIX_CASE_INSENSITIVE("notpostfixi", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return notpostfix(fieldPath, values, requestScope, FOLD_CASE); + } + }, + INFIX("infix", true) { @Override public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { @@ -97,6 +128,13 @@ public Predicate contextualize(Path fieldPath, List values, Reque } }, + NOT_INFIX("notinfix", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return notinfix(fieldPath, values, requestScope, UnaryOperator.identity()); + } + }, + INFIX_CASE_INSENSITIVE("infixi", true) { @Override public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { @@ -104,6 +142,13 @@ public Predicate contextualize(Path fieldPath, List values, Reque } }, + NOT_INFIX_CASE_INSENSITIVE("notinfixi", true) { + @Override + public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { + return notinfix(fieldPath, values, requestScope, FOLD_CASE); + } + }, + ISNULL("isnull", false) { @Override public Predicate contextualize(Path fieldPath, List values, RequestScope requestScope) { @@ -208,24 +253,31 @@ public Predicate contextualize(Path fieldPath, List values, Reque // initialize negated values static { - GE.negated = LT; - GT.negated = LE; - LE.negated = GT; - LT.negated = GE; - IN.negated = NOT; - IN_INSENSITIVE.negated = NOT_INSENSITIVE; - NOT.negated = IN; - NOT_INSENSITIVE.negated = IN_INSENSITIVE; - TRUE.negated = FALSE; - FALSE.negated = TRUE; - ISNULL.negated = NOTNULL; - NOTNULL.negated = ISNULL; - ISEMPTY.negated = NOTEMPTY; - NOTEMPTY.negated = ISEMPTY; - HASMEMBER.negated = HASNOMEMBER; - HASNOMEMBER.negated = HASMEMBER; - BETWEEN.negated = NOTBETWEEN; - NOTBETWEEN.negated = BETWEEN; + var operators = Map.ofEntries( + entry(GE, LT), + entry(GT, LE), + entry(IN, NOT), + entry(IN_INSENSITIVE, NOT_INSENSITIVE), + entry(TRUE, FALSE), + entry(ISNULL, NOTNULL), + entry(ISEMPTY, NOTEMPTY), + entry(HASMEMBER, HASNOMEMBER), + entry(BETWEEN, NOTBETWEEN), + entry(PREFIX, NOT_PREFIX), + entry(PREFIX_CASE_INSENSITIVE, NOT_PREFIX_CASE_INSENSITIVE), + entry(INFIX, NOT_INFIX), + entry(INFIX_CASE_INSENSITIVE, NOT_INFIX_CASE_INSENSITIVE), + entry(POSTFIX, NOT_POSTFIX), + entry(POSTFIX_CASE_INSENSITIVE, NOT_POSTFIX_CASE_INSENSITIVE) + ); + + for (var entry : operators.entrySet()) { + var operator = entry.getKey(); + var negated = entry.getValue(); + + operator.negated = negated; + negated.negated = operator; + } } /** @@ -284,7 +336,7 @@ private static Predicate in(Path fieldPath, List values, // // String-like prefix matching with optional transformation private static Predicate prefix(Path fieldPath, List values, - RequestScope requestScope, UnaryOperator transform) { + RequestScope requestScope, UnaryOperator transform) { return (T entity) -> { if (values.size() != 1) { throw new BadRequestException("PREFIX can only take one argument"); @@ -301,10 +353,29 @@ private static Predicate prefix(Path fieldPath, List values, }; } + // String-like prefix matching with optional transformation + private static Predicate notprefix(Path fieldPath, List values, + RequestScope requestScope, UnaryOperator transform) { + return (T entity) -> { + if (values.size() != 1) { + throw new BadRequestException("NOTPREFIX can only take one argument"); + } + + BiPredicate predicate = (a, b) -> { + String lhs = transform.apply(CoerceUtil.coerce(a, String.class)); + String rhs = transform.apply(CoerceUtil.coerce(b, String.class)); + + return lhs != null && rhs != null && !lhs.startsWith(rhs); + }; + + return evaluate(entity, fieldPath, values, predicate, requestScope); + }; + } + // // String-like postfix matching with optional transformation private static Predicate postfix(Path fieldPath, List values, - RequestScope requestScope, UnaryOperator transform) { + RequestScope requestScope, UnaryOperator transform) { return (T entity) -> { if (values.size() != 1) { throw new BadRequestException("POSTFIX can only take one argument"); @@ -321,10 +392,28 @@ private static Predicate postfix(Path fieldPath, List values, }; } + private static Predicate notpostfix(Path fieldPath, List values, + RequestScope requestScope, UnaryOperator transform) { + return (T entity) -> { + if (values.size() != 1) { + throw new BadRequestException("NOTPOSTFIX can only take one argument"); + } + + BiPredicate predicate = (a, b) -> { + String lhs = transform.apply(CoerceUtil.coerce(a, String.class)); + String rhs = transform.apply(CoerceUtil.coerce(b, String.class)); + + return lhs != null && rhs != null && !lhs.endsWith(rhs); + }; + + return evaluate(entity, fieldPath, values, predicate, requestScope); + }; + } + // // String-like infix matching with optional transformation private static Predicate infix(Path fieldPath, List values, - RequestScope requestScope, UnaryOperator transform) { + RequestScope requestScope, UnaryOperator transform) { return (T entity) -> { if (values.size() != 1) { throw new BadRequestException("INFIX can only take one argument"); @@ -341,6 +430,24 @@ private static Predicate infix(Path fieldPath, List values, }; } + private static Predicate notinfix(Path fieldPath, List values, + RequestScope requestScope, UnaryOperator transform) { + return (T entity) -> { + if (values.size() != 1) { + throw new BadRequestException("NOTINFIX can only take one argument"); + } + + BiPredicate predicate = (a, b) -> { + String lhs = transform.apply(CoerceUtil.coerce(a, String.class)); + String rhs = transform.apply(CoerceUtil.coerce(b, String.class)); + + return lhs != null && rhs != null && !lhs.contains(rhs); + }; + + return evaluate(entity, fieldPath, values, predicate, requestScope); + }; + } + // // Null checking private static Predicate isNull(Path fieldPath, RequestScope requestScope) { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialect.java index 91b56fcad1..8f4160539f 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialect.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialect.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -47,6 +47,8 @@ import cz.jirutka.rsql.parser.ast.OrNode; import cz.jirutka.rsql.parser.ast.RSQLOperators; import cz.jirutka.rsql.parser.ast.RSQLVisitor; +import lombok.Builder; +import lombok.NonNull; import java.io.UnsupportedEncodingException; import java.util.ArrayList; @@ -95,17 +97,29 @@ public class RSQLFilterDialect implements FilterDialect, SubqueryFilterDialect, private final RSQLParser parser; + + @NonNull private final EntityDictionary dictionary; private final CaseSensitivityStrategy caseSensitivityStrategy; + private final Boolean addDefaultArguments; - public RSQLFilterDialect(EntityDictionary dictionary) { - this(dictionary, new CaseSensitivityStrategy.UseColumnCollation()); - } - - public RSQLFilterDialect(EntityDictionary dictionary, CaseSensitivityStrategy caseSensitivityStrategy) { + @Builder + public RSQLFilterDialect(EntityDictionary dictionary, + CaseSensitivityStrategy caseSensitivityStrategy, + Boolean addDefaultArguments) { parser = new RSQLParser(getDefaultOperatorsWithIsnull()); this.dictionary = dictionary; - this.caseSensitivityStrategy = caseSensitivityStrategy; + if (caseSensitivityStrategy == null) { + this.caseSensitivityStrategy = new CaseSensitivityStrategy.UseColumnCollation(); + } else { + this.caseSensitivityStrategy = caseSensitivityStrategy; + } + + if (addDefaultArguments == null) { + this.addDefaultArguments = true; + } else { + this.addDefaultArguments = addDefaultArguments; + } } //add rsql isnull op to the default ops @@ -346,7 +360,10 @@ private Path buildPath(Type rootEntityType, String selector) { arguments = new HashSet<>(); } - addDefaultArguments(arguments, dictionary.getAttributeArguments(entityType, associationName)); + if (addDefaultArguments) { + addDefaultArguments(arguments, dictionary.getAttributeArguments(entityType, associationName)); + } + String typeName = dictionary.getJsonAliasFor(entityType); Type fieldType = dictionary.getParameterizedType(entityType, associationName); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/DefaultFilterDialect.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/DefaultFilterDialect.java index 9fa5457c9e..efca452648 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/DefaultFilterDialect.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/dialect/jsonapi/DefaultFilterDialect.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitor.java index a8ffe8a58a..84d949123a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitor.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterPredicatePushdownExtractor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterPredicatePushdownExtractor.java index 9873d2908c..c424e8ff59 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterPredicatePushdownExtractor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/FilterPredicatePushdownExtractor.java @@ -6,11 +6,9 @@ package com.yahoo.elide.core.filter.expression; -import com.yahoo.elide.core.Path; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.filter.predicates.FilterPredicate; import com.yahoo.elide.core.filter.visitors.FilterExpressionNormalizationVisitor; -import com.yahoo.elide.core.type.Type; /** * Examines a FilterExpression to determine if some or all of it can be pushed to the data store. @@ -26,16 +24,7 @@ public FilterPredicatePushdownExtractor(EntityDictionary dictionary) { @Override public FilterExpression visitPredicate(FilterPredicate filterPredicate) { - boolean filterInMemory = false; - for (Path.PathElement pathElement : filterPredicate.getPath().getPathElements()) { - Type entityClass = pathElement.getType(); - String fieldName = pathElement.getFieldName(); - - if (dictionary.isComputed(entityClass, fieldName)) { - filterInMemory = true; - } - } - + boolean filterInMemory = filterPredicate.getPath().isComputed(dictionary); return (filterInMemory) ? null : filterPredicate; } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryExecutionVerifier.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryExecutionVerifier.java index 9e696cbac9..8749c59433 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryExecutionVerifier.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/expression/InMemoryExecutionVerifier.java @@ -6,10 +6,8 @@ package com.yahoo.elide.core.filter.expression; -import com.yahoo.elide.core.Path; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.filter.predicates.FilterPredicate; -import com.yahoo.elide.core.type.Type; /** * Intended to specify whether the expression must be evaluated in memory or can be pushed to the DataStore. @@ -25,15 +23,7 @@ public InMemoryExecutionVerifier(EntityDictionary dictionary) { @Override public Boolean visitPredicate(FilterPredicate filterPredicate) { - for (Path.PathElement pathElement : filterPredicate.getPath().getPathElements()) { - Type entityClass = pathElement.getType(); - String fieldName = pathElement.getFieldName(); - - if (dictionary.isComputed(entityClass, fieldName)) { - return true; - } - } - return false; + return filterPredicate.getPath().isComputed(dictionary); } @Override diff --git a/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionNormalizationVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionNormalizationVisitor.java index 8e2b42bc8d..57b7fda149 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionNormalizationVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/filter/visitors/FilterExpressionNormalizationVisitor.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/CRUDEvent.java b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/CRUDEvent.java index 5baf6a6d41..110f32312f 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/CRUDEvent.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/CRUDEvent.java @@ -8,11 +8,10 @@ import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.DELETE; -import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; import com.yahoo.elide.annotation.LifeCycleHookBinding; -import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.PersistentResource; import lombok.AllArgsConstructor; import lombok.Data; @@ -40,8 +39,4 @@ public boolean isUpdateEvent() { public boolean isDeleteEvent() { return eventType == DELETE; } - - public boolean isReadEvent() { - return eventType == READ; - } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifeCycleHook.java b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifeCycleHook.java index 9070a81edd..286dc021a9 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifeCycleHook.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifeCycleHook.java @@ -25,9 +25,28 @@ public interface LifeCycleHook { * @param requestScope The request scope * @param changes Optionally, the changes that were made to the entity */ - public abstract void execute(LifeCycleHookBinding.Operation operation, - LifeCycleHookBinding.TransactionPhase phase, - T elideEntity, - RequestScope requestScope, - Optional changes); + void execute(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + T elideEntity, + RequestScope requestScope, + Optional changes); + + /** + * Base method of life cycle hook invoked by Elide. Includes access to the underlying + * CRUDEvent and the PersistentResource. + * @param operation CREATE, READ, UPDATE, or DELETE + * @param phase PRESECURITY, PRECOMMIT or POSTCOMMIT + * @param event The CRUD Event that triggered this hook. + */ + default void execute( + LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + CRUDEvent event) { + this.execute( + operation, + phase, + (T) event.getResource().getObject(), + event.getResource().getRequestScope(), + event.getChanges()); + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifecycleHookInvoker.java b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifecycleHookInvoker.java index 0cb1c29680..8ffbc70b2f 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifecycleHookInvoker.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/lifecycle/LifecycleHookInvoker.java @@ -7,40 +7,26 @@ import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.core.dictionary.EntityDictionary; -import io.reactivex.Observer; -import io.reactivex.disposables.Disposable; import java.util.ArrayList; -import java.util.Optional; /** * RX Java Observer which invokes a lifecycle hook function. */ -public class LifecycleHookInvoker implements Observer { +public class LifecycleHookInvoker { private EntityDictionary dictionary; private LifeCycleHookBinding.Operation op; private LifeCycleHookBinding.TransactionPhase phase; - private Optional exception; - private boolean throwsExceptions; public LifecycleHookInvoker(EntityDictionary dictionary, LifeCycleHookBinding.Operation op, - LifeCycleHookBinding.TransactionPhase phase, - boolean throwExceptions) { + LifeCycleHookBinding.TransactionPhase phase) { this.dictionary = dictionary; this.op = op; this.phase = phase; - this.exception = Optional.empty(); - this.throwsExceptions = throwExceptions; } - @Override - public void onSubscribe(Disposable disposable) { - //NOOP - } - - @Override public void onNext(CRUDEvent event) { ArrayList hooks = new ArrayList<>(); @@ -52,36 +38,9 @@ public void onNext(CRUDEvent event) { hooks.addAll(dictionary.getTriggers(event.getResource().getResourceType(), op, phase)); } - try { - //Invoke all the hooks - hooks.forEach(hook -> - hook.execute( - this.op, - this.phase, - event.getResource().getObject(), - event.getResource().getRequestScope(), - event.getChanges()) - - ); - } catch (RuntimeException e) { - exception = Optional.of(e); - if (throwsExceptions) { - throw e; - } - } - } - - @Override - public void onError(Throwable throwable) { - //NOOP - } - - @Override - public void onComplete() { - //NOOP - } - - public void throwOnError() { - exception.ifPresent(e -> { throw e; }); + //Invoke all the hooks + hooks.forEach(hook -> + hook.execute(this.op, this.phase, event) + ); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/request/Argument.java b/elide-core/src/main/java/com/yahoo/elide/core/request/Argument.java index 472d323687..b847fc2d26 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/request/Argument.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/request/Argument.java @@ -11,6 +11,7 @@ import lombok.NonNull; import lombok.Value; +import java.io.Serializable; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; @@ -27,7 +28,8 @@ */ @Value @Builder -public class Argument { +public class Argument implements Serializable { + private static final long serialVersionUID = 2913180218704512683L; // square brackets having non-empty argument name and encoded agument value separated by ':' // eg: [grain:month] , [foo:bar][blah:Encoded+Value] diff --git a/elide-core/src/main/java/com/yahoo/elide/core/request/Attribute.java b/elide-core/src/main/java/com/yahoo/elide/core/request/Attribute.java index 4e664e0591..b1905f3f44 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/request/Attribute.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/request/Attribute.java @@ -14,6 +14,7 @@ import lombok.Singular; import lombok.ToString; +import java.io.Serializable; import java.util.Set; /** @@ -21,7 +22,9 @@ */ @Data @Builder -public class Attribute { +public class Attribute implements Serializable { + private static final long serialVersionUID = 3009706331255770579L; + @NonNull @ToString.Exclude private Type type; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/RequestScope.java b/elide-core/src/main/java/com/yahoo/elide/core/security/RequestScope.java index fb57d3588a..75f9f94642 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/RequestScope.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/RequestScope.java @@ -6,6 +6,7 @@ package com.yahoo.elide.core.security; import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.jsonapi.document.processors.WithMetadata; import java.util.List; import java.util.Map; @@ -13,7 +14,7 @@ /** * The request scope interface passed to checks. */ -public interface RequestScope { +public interface RequestScope extends WithMetadata { User getUser(); String getApiVersion(); String getRequestHeaderByName(String headerName); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Role.java b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Role.java index 530b9a4319..28308bc551 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Role.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/checks/prefab/Role.java @@ -12,6 +12,8 @@ * Simple checks to always grant or deny. */ public class Role { + public static final String NONE_ROLE = "NONE"; + public static final String ALL_ROLE = "ALL"; /** * Check which always grants. */ diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionExpressionBuilder.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionExpressionBuilder.java index 74bb6a3ba0..ae69fe7f89 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionExpressionBuilder.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/PermissionExpressionBuilder.java @@ -303,7 +303,7 @@ private Expression buildAnyFieldExpression(final PermissionCondition condition, Expression entityExpression = normalizedExpressionFromParseTree(classPermissions, checkFn); OrExpression allFieldsExpression = new OrExpression(FAILURE, null); - List fields = entityDictionary.getAllFields(resourceClass); + List fields = entityDictionary.getAllExposedFields(resourceClass); boolean entityExpressionUsed = false; boolean fieldExpressionUsed = false; @@ -363,7 +363,7 @@ private Expression buildAnyFieldOnlyExpression(final PermissionCondition conditi Class annotationClass = condition.getPermission(); OrExpression allFieldsExpression = new OrExpression(FAILURE, null); - List fields = entityDictionary.getAllFields(resourceClass); + List fields = entityDictionary.getAllExposedFields(resourceClass); boolean fieldExpressionUsed = false; @@ -416,7 +416,7 @@ public FilterExpression buildAnyFieldFilterExpression( } FilterExpression allFieldsFilterExpression = entityFilter; - List fields = entityDictionary.getAllFields(forType).stream() + List fields = entityDictionary.getAllExposedFields(forType).stream() .filter(field -> requestedFields == null || requestedFields.contains(field)) .collect(Collectors.toList()); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AnyFieldExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AnyFieldExpression.java index c34d1123b6..7c0ebecc43 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AnyFieldExpression.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/AnyFieldExpression.java @@ -32,7 +32,7 @@ public ExpressionResult evaluate(EvaluationMode mode) { @Override public T accept(ExpressionVisitor visitor) { - return visitor.visitExpression(this); + return visitor.visitAnyFieldExpression(this); } @Override diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/BooleanExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/BooleanExpression.java new file mode 100644 index 0000000000..4707991739 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/BooleanExpression.java @@ -0,0 +1,41 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.security.permissions.expressions; + +import static org.fusesource.jansi.Ansi.ansi; + +import com.yahoo.elide.core.security.permissions.ExpressionResult; +import org.fusesource.jansi.Ansi; + +/** + * Expression that returns always true (PASS) or false (FAILURE). + */ +public class BooleanExpression implements Expression { + private boolean value; + public BooleanExpression(boolean value) { + this.value = value; + } + @Override + public ExpressionResult evaluate(EvaluationMode mode) { + if (value == true) { + return ExpressionResult.PASS; + } + return ExpressionResult.FAIL; + } + + @Override + public T accept(ExpressionVisitor visitor) { + return visitor.visitBooleanExpression(this); + } + + @Override + public String toString() { + Ansi.Color color = value ? Ansi.Color.GREEN : Ansi.Color.RED; + String label = value ? "SUCCESS" : "FAILURE"; + return ansi().fg(color).a(label).reset().toString(); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/Expression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/Expression.java index 7ea6e20d29..af4da84936 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/Expression.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/Expression.java @@ -5,9 +5,7 @@ */ package com.yahoo.elide.core.security.permissions.expressions; -import static org.fusesource.jansi.Ansi.ansi; import com.yahoo.elide.core.security.permissions.ExpressionResult; -import org.fusesource.jansi.Ansi; /** * Interface describing an expression. @@ -37,37 +35,7 @@ public enum EvaluationMode { * Static Expressions that return PASS or FAIL. */ public static class Results { - public static final Expression SUCCESS = new Expression() { - @Override - public ExpressionResult evaluate(EvaluationMode ignored) { - return ExpressionResult.PASS; - } - - @Override - public T accept(ExpressionVisitor visitor) { - return visitor.visitExpression(this); - } - - @Override - public String toString() { - return ansi().fg(Ansi.Color.GREEN).a("SUCCESS").reset().toString(); - } - }; - public static final Expression FAILURE = new Expression() { - @Override - public ExpressionResult evaluate(EvaluationMode ignored) { - return ExpressionResult.FAIL; - } - - @Override - public T accept(ExpressionVisitor visitor) { - return visitor.visitExpression(this); - } - - @Override - public String toString() { - return ansi().fg(Ansi.Color.RED).a("FAILURE").reset().toString(); - } - }; + public static final Expression SUCCESS = new BooleanExpression(true); + public static final Expression FAILURE = new BooleanExpression(false); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/ExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/ExpressionVisitor.java index a3d48fa6bd..6eb8b3430d 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/ExpressionVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/ExpressionVisitor.java @@ -11,7 +11,9 @@ * @param The return type of the visitor */ public interface ExpressionVisitor { - T visitExpression(Expression expression); + T visitSpecificFieldExpression(SpecificFieldExpression expression); + T visitAnyFieldExpression(AnyFieldExpression expression); + T visitBooleanExpression(BooleanExpression expression); T visitCheckExpression(CheckExpression checkExpression); T visitAndExpression(AndExpression andExpression); T visitOrExpression(OrExpression orExpression); diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/SpecificFieldExpression.java b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/SpecificFieldExpression.java index 802c68c9eb..0bf62878a1 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/SpecificFieldExpression.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/permissions/expressions/SpecificFieldExpression.java @@ -41,7 +41,7 @@ public ExpressionResult evaluate(EvaluationMode mode) { @Override public T accept(ExpressionVisitor visitor) { - return visitor.visitExpression(this); + return visitor.visitSpecificFieldExpression(this); } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/CanPaginateVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/CanPaginateVisitor.java index 821e685c33..3dad96a4e9 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/CanPaginateVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/CanPaginateVisitor.java @@ -248,7 +248,7 @@ public static boolean canPaginate( canPaginateClass = visitor.visit(classPermissions); } - List fields = dictionary.getAllFields(resourceClass); + List fields = dictionary.getAllExposedFields(resourceClass); boolean canPaginate = true; for (String field : fields) { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionNormalizationVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionNormalizationVisitor.java index 06529a7fc1..626ce1a77b 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionNormalizationVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionExpressionNormalizationVisitor.java @@ -6,19 +6,24 @@ package com.yahoo.elide.core.security.visitors; -import com.yahoo.elide.core.security.permissions.expressions.AndExpression; -import com.yahoo.elide.core.security.permissions.expressions.CheckExpression; -import com.yahoo.elide.core.security.permissions.expressions.Expression; -import com.yahoo.elide.core.security.permissions.expressions.ExpressionVisitor; -import com.yahoo.elide.core.security.permissions.expressions.NotExpression; -import com.yahoo.elide.core.security.permissions.expressions.OrExpression; +import com.yahoo.elide.core.security.permissions.expressions.*; /** * Expression Visitor to normalize Permission expression. */ public class PermissionExpressionNormalizationVisitor implements ExpressionVisitor { @Override - public Expression visitExpression(Expression expression) { + public Expression visitSpecificFieldExpression(SpecificFieldExpression expression) { + return expression; + } + + @Override + public Expression visitAnyFieldExpression(AnyFieldExpression expression) { + return expression; + } + + @Override + public Expression visitBooleanExpression(BooleanExpression expression) { return expression; } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionToFilterExpressionVisitor.java b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionToFilterExpressionVisitor.java index 503a74eb46..9a0a596486 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionToFilterExpressionVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/security/visitors/PermissionToFilterExpressionVisitor.java @@ -18,12 +18,7 @@ import com.yahoo.elide.core.security.checks.Check; import com.yahoo.elide.core.security.checks.FilterExpressionCheck; import com.yahoo.elide.core.security.checks.UserCheck; -import com.yahoo.elide.core.security.permissions.expressions.AndExpression; -import com.yahoo.elide.core.security.permissions.expressions.CheckExpression; -import com.yahoo.elide.core.security.permissions.expressions.Expression; -import com.yahoo.elide.core.security.permissions.expressions.ExpressionVisitor; -import com.yahoo.elide.core.security.permissions.expressions.NotExpression; -import com.yahoo.elide.core.security.permissions.expressions.OrExpression; +import com.yahoo.elide.core.security.permissions.expressions.*; import com.yahoo.elide.core.type.Type; import java.util.Objects; @@ -199,7 +194,17 @@ private Operator operator(FilterExpression expression) { } @Override - public FilterExpression visitExpression(Expression expression) { + public FilterExpression visitSpecificFieldExpression(SpecificFieldExpression expression) { + return NO_EVALUATION_EXPRESSION; + } + + @Override + public FilterExpression visitAnyFieldExpression(AnyFieldExpression expression) { + return NO_EVALUATION_EXPRESSION; + } + + @Override + public FilterExpression visitBooleanExpression(BooleanExpression expression) { return NO_EVALUATION_EXPRESSION; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/ClassType.java b/elide-core/src/main/java/com/yahoo/elide/core/type/ClassType.java index ac02fc28d6..09331cd570 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/type/ClassType.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/ClassType.java @@ -179,7 +179,7 @@ private static Field constructField(java.lang.reflect.Field field) { return null; } - return new FieldType(field); + return new EntityFieldType(field); } private static Method constructMethod(java.lang.reflect.Executable method) { @@ -187,7 +187,7 @@ private static Method constructMethod(java.lang.reflect.Executable method) { return null; } - return new MethodType(method); + return new EntityMethodType(method); } private static Package constructPackage(java.lang.Package pkg) { diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Dynamic.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Dynamic.java index 0c0461e6cb..17abd255b3 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/type/Dynamic.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Dynamic.java @@ -6,11 +6,13 @@ package com.yahoo.elide.core.type; +import java.io.Serializable; + /** * All objects created or loaded by a DataStore that are not associated with a ClassType * must inherit from this interface. */ -public interface Dynamic { +public interface Dynamic extends Serializable { /** * Get the underlying Elide type associated with this object. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/EntityFieldType.java b/elide-core/src/main/java/com/yahoo/elide/core/type/EntityFieldType.java new file mode 100644 index 0000000000..981b120878 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/EntityFieldType.java @@ -0,0 +1,53 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.type; + +import lombok.EqualsAndHashCode; + +import java.util.Optional; + +import javax.persistence.ManyToMany; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import javax.persistence.OneToOne; + +/** + * Elide field that wraps a Java field for a JPA entity. If the field is a relationship with targetEntity set, + * the type of the field is the targetEntity. + */ + +@EqualsAndHashCode(callSuper = true) +public class EntityFieldType extends FieldType { + + Type targetEntity = null; + boolean toMany = false; + + public EntityFieldType(java.lang.reflect.Field field) { + super(field); + + Class entityType = null; + if (field.isAnnotationPresent(ManyToMany.class)) { + entityType = field.getAnnotation(ManyToMany.class).targetEntity(); + toMany = true; + } else if (field.isAnnotationPresent(OneToMany.class)) { + entityType = field.getAnnotation(OneToMany.class).targetEntity(); + toMany = true; + } else if (field.isAnnotationPresent(OneToOne.class)) { + entityType = field.getAnnotation(OneToOne.class).targetEntity(); + } else if (field.isAnnotationPresent(ManyToOne.class)) { + entityType = field.getAnnotation(ManyToOne.class).targetEntity(); + } + targetEntity = entityType == null || entityType.equals(void.class) ? null : ClassType.of(entityType); + } + + @Override + public Type getParameterizedType(Type parentType, Optional index) { + if (targetEntity != null) { + return targetEntity; + } + return super.getParameterizedType(parentType, index); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/EntityMethodType.java b/elide-core/src/main/java/com/yahoo/elide/core/type/EntityMethodType.java new file mode 100644 index 0000000000..458b686a96 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/EntityMethodType.java @@ -0,0 +1,54 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.type; + +import lombok.EqualsAndHashCode; + +import java.util.Optional; + +import javax.persistence.ManyToMany; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import javax.persistence.OneToOne; + +/** + * Elide Method that wraps a Java Method for a JPA entity. If the method is a relationship with targetEntity set, + * the type of the method is the targetEntity. + */ +@EqualsAndHashCode(callSuper = true) +public class EntityMethodType extends MethodType { + + Type targetEntity = null; + boolean toMany = false; + + public EntityMethodType(java.lang.reflect.Executable method) { + super(method); + + Class entityType = null; + if (method.isAnnotationPresent(ManyToMany.class)) { + entityType = method.getAnnotation(ManyToMany.class).targetEntity(); + toMany = true; + } else if (method.isAnnotationPresent(OneToMany.class)) { + entityType = method.getAnnotation(OneToMany.class).targetEntity(); + toMany = true; + } else if (method.isAnnotationPresent(OneToOne.class)) { + entityType = method.getAnnotation(OneToOne.class).targetEntity(); + } else if (method.isAnnotationPresent(ManyToOne.class)) { + entityType = method.getAnnotation(ManyToOne.class).targetEntity(); + } + + targetEntity = entityType == null || entityType.equals(void.class) ? null : ClassType.of(entityType); + } + + @Override + public Type getParameterizedReturnType(Type parentType, Optional index) { + if (targetEntity != null) { + return targetEntity; + } + + return super.getParameterizedReturnType(parentType, index); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/FieldType.java b/elide-core/src/main/java/com/yahoo/elide/core/type/FieldType.java index b758b55b2e..e43b6eddb6 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/type/FieldType.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/FieldType.java @@ -19,6 +19,7 @@ @AllArgsConstructor @EqualsAndHashCode public class FieldType implements Field { + private static final long serialVersionUID = -1949519786163885434L; private java.lang.reflect.Field field; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Member.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Member.java index e1afbeab41..4e36581af2 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/type/Member.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Member.java @@ -6,10 +6,12 @@ package com.yahoo.elide.core.type; +import java.io.Serializable; + /** * Base class of fields and methods. */ -public interface Member { +public interface Member extends Serializable { /** * Get the permission modifiers of the field/method. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Package.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Package.java index b7e7db0ccf..d64a2887ba 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/type/Package.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Package.java @@ -6,12 +6,13 @@ package com.yahoo.elide.core.type; +import java.io.Serializable; import java.lang.annotation.Annotation; /** * Elide package for one or more types. */ -public interface Package { +public interface Package extends Serializable { /** * Gets the annotations of a specific type. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedAttribute.java b/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedAttribute.java index f031aa4cd7..1230915cd2 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedAttribute.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedAttribute.java @@ -8,13 +8,14 @@ import com.yahoo.elide.core.request.Argument; +import java.io.Serializable; import java.util.Set; /** * An elide attribute that supports parameters. */ @FunctionalInterface -public interface ParameterizedAttribute { +public interface ParameterizedAttribute extends Serializable { /** * Fetch the attribute value with the specified parameters. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedModel.java b/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedModel.java index a76d1c89fd..2513b413e6 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedModel.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/ParameterizedModel.java @@ -11,6 +11,7 @@ import com.yahoo.elide.core.request.Argument; import com.yahoo.elide.core.request.Attribute; +import java.io.Serializable; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -20,7 +21,8 @@ /** * Base class that contains one or more parameterized attributes. */ -public abstract class ParameterizedModel { +public abstract class ParameterizedModel implements Serializable { + private static final long serialVersionUID = -519263564697315522L; @Exclude protected Map parameterizedAttributes; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/type/Type.java b/elide-core/src/main/java/com/yahoo/elide/core/type/Type.java index 46f9a38a12..a05cb1468d 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/type/Type.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/type/Type.java @@ -6,6 +6,7 @@ package com.yahoo.elide.core.type; +import java.io.Serializable; import java.lang.annotation.Annotation; import java.util.Optional; @@ -13,7 +14,8 @@ * Elide type for models and their attributes. * @param The underlying Java class. */ -public interface Type extends java.lang.reflect.Type { +public interface Type extends java.lang.reflect.Type, Serializable { + static final long serialVersionUID = -51926356467315522L; /** * Gets the canonical name of the class containing no $ symbols. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/ClassScanner.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/ClassScanner.java index 8fa63ead14..307aea8e7b 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/utils/ClassScanner.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/ClassScanner.java @@ -49,7 +49,7 @@ public interface ClassScanner { */ Set> getAnnotatedClasses(List> annotations); - Set> getAnnotatedClasses(Class ...annotations); + Set> getAnnotatedClasses(Class ... annotations); /** * Returns all classes within a package. diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/DefaultClassScanner.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/DefaultClassScanner.java index 65bb90afa1..087c3c8352 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/utils/DefaultClassScanner.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/DefaultClassScanner.java @@ -1,10 +1,13 @@ /* - * Copyright 2015, Yahoo Inc. + * Copyright 2021, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.core.utils; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.SecurityCheck; +import com.yahoo.elide.core.utils.coerce.converters.ElideTypeConverter; import io.github.classgraph.ClassGraph; import io.github.classgraph.ClassInfo; import io.github.classgraph.ScanResult; @@ -12,7 +15,7 @@ import java.lang.annotation.Annotation; import java.util.Arrays; import java.util.HashMap; -import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -29,14 +32,19 @@ public class DefaultClassScanner implements ClassScanner { */ private static final String [] CACHE_ANNOTATIONS = { //Elide Core Annotations - "com.yahoo.elide.annotation.Include", - "com.yahoo.elide.annotation.SecurityCheck", - "com.yahoo.elide.core.utils.coerce.converters.ElideTypeConverter", + Include.class.getCanonicalName(), + SecurityCheck.class.getCanonicalName(), + ElideTypeConverter.class.getCanonicalName(), - //Aggregation Store Annotations + //GraphQL annotations. Strings here to avoid dependency. + "com.yahoo.elide.graphql.subscriptions.annotations.Subscription", + + //Aggregation Store Annotations. Strings here to avoid dependency. "com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable", "com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery", "org.hibernate.annotations.Subselect", + + //JPA "javax.persistence.Entity", "javax.persistence.Table" }; @@ -66,7 +74,7 @@ public DefaultClassScanner() { startupCache.put(annotationName, scanResult.getClassesWithAnnotation(annotationName) .stream() .map(ClassInfo::loadClass) - .collect(Collectors.toSet())); + .collect(Collectors.toCollection(LinkedHashSet::new))); } } @@ -83,18 +91,18 @@ public Set> getAnnotatedClasses(String packageName, Class clazz.getPackage().getName().equals(packageName) || clazz.getPackage().getName().startsWith(packageName + ".")) - .collect(Collectors.toSet()); + .collect(Collectors.toCollection(LinkedHashSet::new)); } @Override public Set> getAnnotatedClasses(List> annotations, FilterExpression filter) { - Set> result = new HashSet<>(); + Set> result = new LinkedHashSet<>(); for (Class annotation : annotations) { result.addAll(startupCache.get(annotation.getCanonicalName()).stream() .filter(filter::include) - .collect(Collectors.toSet())); + .collect(Collectors.toCollection(LinkedHashSet::new))); } return result; @@ -106,7 +114,7 @@ public Set> getAnnotatedClasses(List> annot } @Override - public Set> getAnnotatedClasses(Class ...annotations) { + public Set> getAnnotatedClasses(Class ... annotations) { return getAnnotatedClasses(Arrays.asList(annotations)); } @@ -116,7 +124,7 @@ public Set> getAllClasses(String packageName) { .enableClassInfo().whitelistPackages(packageName).scan()) { return scanResult.getAllClasses().stream() .map((ClassInfo::loadClass)) - .collect(Collectors.toSet()); + .collect(Collectors.toCollection(LinkedHashSet::new)); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/TypeHelper.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/TypeHelper.java index a94f7d356b..e84f4ad767 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/utils/TypeHelper.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/TypeHelper.java @@ -1,5 +1,5 @@ /* - * Copyright 2019, Oath Inc. + * Copyright 2019, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/CoerceUtil.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/CoerceUtil.java index 58dde6a070..676e637ee9 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/CoerceUtil.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/CoerceUtil.java @@ -23,7 +23,7 @@ import java.lang.reflect.Array; import java.util.Collections; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -36,7 +36,7 @@ public class CoerceUtil { private static final ToEnumConverter TO_ENUM_CONVERTER = new ToEnumConverter(); private static final ToUUIDConverter TO_UUID_CONVERTER = new ToUUIDConverter(); private static final FromMapConverter FROM_MAP_CONVERTER = new FromMapConverter(); - private static final Map, Serde> SERDES = new HashMap<>(); + private static final Map, Serde> SERDES = new LinkedHashMap<>(); private static final BeanUtilsBean BEAN_UTILS_BEAN_INSTANCE = setup(); private static final Set INITIALIZED_CLASSLOADERS = Collections.newSetFromMap(new MapMaker().weakKeys().makeMap()); @@ -116,7 +116,11 @@ private static BeanUtilsBean setup() { */ public Converter lookup(Class sourceType, Class targetType) { if (targetType.isEnum()) { - return TO_ENUM_CONVERTER; + + //Only use the default ENUM converter if there is no registered Serde for the given Enum type. + if (! SERDES.containsKey(targetType)) { + return TO_ENUM_CONVERTER; + } } if (Map.class.isAssignableFrom(sourceType)) { return FROM_MAP_CONVERTER; diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/EpochToDateConverter.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/EpochToDateConverter.java index e54c990646..6bdf071bfa 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/EpochToDateConverter.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/EpochToDateConverter.java @@ -36,8 +36,7 @@ public T convert(Class cls, Object value) { return numberToDate(cls, (Number) value); } throw new UnsupportedOperationException(value.getClass().getSimpleName() + " is not a valid epoch"); - } catch (IndexOutOfBoundsException | ReflectiveOperationException - | UnsupportedOperationException | IllegalArgumentException e) { + } catch (IndexOutOfBoundsException | UnsupportedOperationException | IllegalArgumentException e) { throw new InvalidAttributeException("Unknown " + cls.getSimpleName() + " value " + value, e); } } @@ -52,7 +51,7 @@ public Object serialize(T val) { return val.getTime(); } - private static T numberToDate(Class cls, Number epoch) throws ReflectiveOperationException { + private static T numberToDate(Class cls, Number epoch) { if (ClassUtils.isAssignable(cls, java.sql.Date.class)) { return cls.cast(new java.sql.Date(epoch.longValue())); } @@ -68,7 +67,7 @@ private static T numberToDate(Class cls, Number epoch) throws ReflectiveO throw new UnsupportedOperationException("Cannot convert to " + cls.getSimpleName()); } - private static T stringToDate(Class cls, String epoch) throws ReflectiveOperationException { + private static T stringToDate(Class cls, String epoch) { return numberToDate(cls, Long.parseLong(epoch)); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToEnumConverter.java b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToEnumConverter.java index 246cdf001e..c11d4f8d46 100644 --- a/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToEnumConverter.java +++ b/elide-core/src/main/java/com/yahoo/elide/core/utils/coerce/converters/ToEnumConverter.java @@ -31,6 +31,9 @@ public T convert(Class cls, Object value) { if (ClassUtils.isAssignable(value.getClass(), Integer.class, true)) { return intToEnum(cls, (Integer) value); } + if (ClassUtils.isAssignable(value.getClass(), Long.class, true)) { + return intToEnum(cls, ((Long) value).intValue()); + } throw new UnsupportedOperationException(value.getClass().getSimpleName() + " to Enum no supported"); } catch (IndexOutOfBoundsException | ReflectiveOperationException | UnsupportedOperationException | IllegalArgumentException e) { diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java index ae872fa8d9..26120ca26a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/EntityProjectionMaker.java @@ -13,6 +13,7 @@ import com.yahoo.elide.core.exceptions.InvalidValueException; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.pagination.PaginationImpl; +import com.yahoo.elide.core.request.Argument; import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Pagination; @@ -75,6 +76,7 @@ public EntityProjection parsePath(String path) { public EntityProjection parseInclude(Type entityClass) { return EntityProjection.builder() .type(entityClass) + .arguments(getDefaultEntityArguments(entityClass)) .relationships(toRelationshipSet(getIncludedRelationships(entityClass))) .build(); } @@ -145,6 +147,7 @@ public Function, NamedEntityProjection> visitRelationship(CoreParser.Rel .filterExpression(filter) .sorting(sorting) .pagination(pagination) + .arguments(getDefaultEntityArguments(entityClass)) .type(entityClass) .build() ).build(); @@ -162,6 +165,7 @@ public Function, NamedEntityProjection> visitEntity(CoreParser.EntityCon .name(entityName) .projection(EntityProjection.builder() .type(entityClass) + .arguments(getDefaultEntityArguments(entityClass)) .attributes(getSparseAttributes(entityClass)) .relationships(toRelationshipSet(getRequiredRelationships(entityClass))) .build() @@ -196,6 +200,7 @@ public EntityProjection visitIncludePath(Path path) { .attributes(getSparseAttributes(entityClass)) .filterExpression(scope.getFilterExpressionByType(entityClass).orElse(null)) .type(entityClass) + .arguments(getDefaultEntityArguments(entityClass)) .build(); } @@ -203,6 +208,7 @@ public EntityProjection visitIncludePath(Path path) { .relationships(toRelationshipSet(getSparseRelationships(entityClass))) .attributes(getSparseAttributes(entityClass)) .type(entityClass) + .arguments(getDefaultEntityArguments(entityClass)) .filterExpression(scope.getFilterExpressionByType(entityClass).orElse(null)) .build(); } @@ -220,6 +226,7 @@ private Function, NamedEntityProjection> visitEntityWithSubCollection(Co .name(entityName) .projection(EntityProjection.builder() .type(entityClass) + .arguments(getDefaultEntityArguments(entityClass)) .relationship(projection.name, projection.projection) .build() ).build(); @@ -242,6 +249,7 @@ private Function, NamedEntityProjection> visitEntityWithRelationship(Cor .name(entityName) .projection(EntityProjection.builder() .type(entityClass) + .arguments(getDefaultEntityArguments(entityClass)) .filterExpression(filter) .relationships(toRelationshipSet(getRequiredRelationships(entityClass))) .relationship(relationshipName, relationshipProjection.projection) @@ -280,6 +288,7 @@ private Function, NamedEntityProjection> visitTerminalCollection(CorePar .pagination(pagination) .relationships(toRelationshipSet(getRequiredRelationships(entityClass))) .attributes(getSparseAttributes(entityClass)) + .arguments(getDefaultEntityArguments(entityClass)) .type(entityClass) .build() ).build(); @@ -320,6 +329,30 @@ private Map getIncludedRelationships(Type entityCla return relationships; } + private Set getDefaultAttributeArguments(Type entityClass, String attributeName) { + return dictionary.getAttributeArguments(entityClass, attributeName) + .stream() + .map(argumentType -> { + return Argument.builder() + .name(argumentType.getName()) + .value(argumentType.getDefaultValue()) + .build(); + }) + .collect(Collectors.toSet()); + } + + private Set getDefaultEntityArguments(Type entityClass) { + return dictionary.getEntityArguments(entityClass) + .stream() + .map(argumentType -> { + return Argument.builder() + .name(argumentType.getName()) + .value(argumentType.getDefaultValue()) + .build(); + }) + .collect(Collectors.toSet()); + } + private Set getSparseAttributes(Type entityClass) { Set allAttributes = new LinkedHashSet<>(dictionary.getAttributes(entityClass)); @@ -336,6 +369,7 @@ private Set getSparseAttributes(Type entityClass) { .map(attributeName -> Attribute.builder() .name(attributeName) .type(dictionary.getType(entityClass, attributeName)) + .arguments(getDefaultAttributeArguments(entityClass, attributeName)) .build()) .collect(Collectors.toSet()); } @@ -361,6 +395,7 @@ private Map getSparseRelationships(Type entityClass return EntityProjection.builder() .type(dictionary.getParameterizedType(entityClass, relationshipName)) + .arguments(getDefaultEntityArguments(entityClass)) .filterExpression(filter) .build(); } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java index 0d0f16427e..7eea7df022 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/JsonApiMapper.java @@ -39,17 +39,6 @@ public JsonApiMapper(ObjectMapper mapper) { mapper.registerModule(JsonApiSerializer.getModule()); } - /** - * Write out JSON API Document as a string. - * - * @param jsonApiDocument the json api document - * @return Document as string - * @throws JsonProcessingException the json processing exception - */ - public String writeJsonApiDocument(JsonApiDocument jsonApiDocument) throws JsonProcessingException { - return mapper.writeValueAsString(jsonApiDocument); - } - /** * To json object. * @@ -63,12 +52,13 @@ public JsonNode toJsonObject(JsonApiDocument jsonApiDocument) { /** * Write json api document. * - * @param node the node + * @param doc the document + * @param The type of document object so serialize * @return the string * @throws JsonProcessingException the json processing exception */ - public String writeJsonApiDocument(JsonNode node) throws JsonProcessingException { - return mapper.writeValueAsString(node); + public String writeJsonApiDocument(T doc) throws JsonProcessingException { + return mapper.writeValueAsString(doc); } /** diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/DocumentProcessor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/DocumentProcessor.java index 6c8c442c51..f8667f5adc 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/DocumentProcessor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/DocumentProcessor.java @@ -6,9 +6,10 @@ package com.yahoo.elide.jsonapi.document.processors; import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import java.util.Set; +import java.util.LinkedHashSet; import javax.ws.rs.core.MultivaluedMap; /** @@ -22,20 +23,26 @@ public interface DocumentProcessor { * A method for making transformations to the JsonApiDocument. * * @param jsonApiDocument the json api document + * @param scope the request scope * @param resource the resource * @param queryParams the query params */ - void execute(JsonApiDocument jsonApiDocument, PersistentResource resource, + void execute(JsonApiDocument jsonApiDocument, + RequestScope scope, + PersistentResource resource, MultivaluedMap queryParams); /** * A method for making transformations to the JsonApiDocument. * * @param jsonApiDocument the json api document + * @param scope the request scope * @param resources the resources * @param queryParams the query params */ - void execute(JsonApiDocument jsonApiDocument, Set resources, + void execute(JsonApiDocument jsonApiDocument, + RequestScope scope, + LinkedHashSet resources, MultivaluedMap queryParams); //TODO Possibly add a something like a 'afterExecute' method to process after the first round of execution diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessor.java index 0f1283d93f..a1eb3d2729 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessor.java @@ -6,6 +6,7 @@ package com.yahoo.elide.jsonapi.document.processors; import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Relationship; @@ -33,7 +34,7 @@ public class IncludedProcessor implements DocumentProcessor { * to the included block of the JsonApiDocument. */ @Override - public void execute(JsonApiDocument jsonApiDocument, PersistentResource resource, + public void execute(JsonApiDocument jsonApiDocument, RequestScope scope, PersistentResource resource, MultivaluedMap queryParams) { if (isPresent(queryParams, INCLUDE)) { addIncludedResources(jsonApiDocument, resource, queryParams.get(INCLUDE)); @@ -45,8 +46,12 @@ public void execute(JsonApiDocument jsonApiDocument, PersistentResource resource * to the included block of the JsonApiDocument. */ @Override - public void execute(JsonApiDocument jsonApiDocument, Set resources, - MultivaluedMap queryParams) { + public void execute( + JsonApiDocument jsonApiDocument, + RequestScope scope, + LinkedHashSet resources, + MultivaluedMap queryParams + ) { if (isPresent(queryParams, INCLUDE)) { // Process include for each resource diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/PopulateMetaProcessor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/PopulateMetaProcessor.java new file mode 100644 index 0000000000..8363ebb828 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/PopulateMetaProcessor.java @@ -0,0 +1,61 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.jsonapi.document.processors; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Meta; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Set; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Document processor that populates 'meta' fields for collections (from request scope metadata) and + * resources (from resource metadata). This processor runs after the document has already been populated. + */ +public class PopulateMetaProcessor implements DocumentProcessor { + @Override + public void execute( + JsonApiDocument jsonApiDocument, + RequestScope scope, + PersistentResource persistentResource, + MultivaluedMap queryParams + ) { + + addDocumentMeta(jsonApiDocument, scope); + } + + private void addDocumentMeta(JsonApiDocument document, RequestScope scope) { + Set fields = scope.getMetadataFields(); + if (fields.size() == 0) { + return; + } + Meta meta = document.getMeta(); + if (meta == null) { + meta = new Meta(new HashMap<>()); + } + + for (String field : fields) { + meta.getMetaMap().put(field, scope.getMetadataField(field).get()); + } + + document.setMeta(meta); + } + + @Override + public void execute( + JsonApiDocument jsonApiDocument, + RequestScope scope, + LinkedHashSet resources, + MultivaluedMap queryParams + ) { + addDocumentMeta(jsonApiDocument, scope); + } +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/WithMetadata.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/WithMetadata.java new file mode 100644 index 0000000000..f0a3419669 --- /dev/null +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/document/processors/WithMetadata.java @@ -0,0 +1,38 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.jsonapi.document.processors; + +import java.util.Optional; +import java.util.Set; + +/** + * The class carries metadata fields. + */ +public interface WithMetadata { + + /** + * Sets a metadata property for this request. + * @param property + * @param value + */ + default void setMetadataField(String property, Object value) { + //noop + } + + /** + * Retrieves a metadata property from this request. + * @param property + * @return An optional metadata property. + */ + Optional getMetadataField(String property); + + /** + * Return the set of metadata fields that have been set. + * @return metadata fields that have been set. + */ + Set getMetadataFields(); +} diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java index d9fc4614fb..7c5c43b5a6 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/extensions/JsonApiPatch.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -11,6 +11,7 @@ import com.yahoo.elide.core.exceptions.HttpStatusException; import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; import com.yahoo.elide.core.exceptions.JsonPatchExtensionException; +import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.models.Data; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.jsonapi.models.Patch; @@ -22,7 +23,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.commons.collections4.IterableUtils; import org.apache.commons.lang3.ArrayUtils; @@ -134,13 +134,13 @@ private JsonApiPatch(DataStore dataStore, */ private Supplier> processActions(PatchRequestScope requestScope) { try { - List>> results = handleActions(requestScope); + List>> results = handleActions(requestScope); postProcessRelationships(requestScope); return () -> { try { - return Pair.of(HttpStatus.SC_OK, mergeResponse(results)); + return Pair.of(HttpStatus.SC_OK, mergeResponse(results, requestScope.getMapper())); } catch (HttpStatusException e) { throwErrorResponse(); // NOTE: This should never be called. throwErrorResponse should _always_ throw an exception @@ -160,18 +160,28 @@ private Supplier> processActions(PatchRequestScope reque * @param requestScope outer request scope * @return List of responders */ - private List>> handleActions(PatchRequestScope requestScope) { + private List>> handleActions(PatchRequestScope requestScope) { return actions.stream().map(action -> { - Supplier> result; + Supplier> result; try { + String path = action.patch.getPath(); + if (path == null) { + throw new InvalidEntityBodyException("Patch extension requires all objects " + + "to have an assigned path"); + } String[] combined = ArrayUtils.addAll(rootUri.split("/"), action.patch.getPath().split("/")); String fullPath = String.join("/", combined).replace("/-", ""); - switch (action.patch.getOperation()) { + + Patch.Operation operation = action.patch.getOperation(); + if (operation == null) { + throw new InvalidEntityBodyException("Patch extension operation cannot be null."); + } + switch (operation) { case ADD: result = handleAddOp(fullPath, action.patch.getValue(), requestScope, action); break; case REPLACE: - result = handleReplaceOp(fullPath, action.patch.getValue(), requestScope); + result = handleReplaceOp(fullPath, action.patch.getValue(), requestScope, action); break; case REMOVE: result = handleRemoveOp(fullPath, action.patch.getValue(), requestScope); @@ -191,7 +201,7 @@ private List>> handleActions(PatchRequestScope /** * Add a document via patch extension. */ - private Supplier> handleAddOp( + private Supplier> handleAddOp( String path, JsonNode patchValue, PatchRequestScope requestScope, PatchAction action) { try { JsonApiDocument value = requestScope.getMapper().readJsonApiPatchExtValue(patchValue); @@ -229,10 +239,21 @@ private Supplier> handleAddOp( /** * Replace data via patch extension. */ - private Supplier> handleReplaceOp( - String path, JsonNode patchVal, PatchRequestScope requestScope) { + private Supplier> handleReplaceOp( + String path, JsonNode patchVal, PatchRequestScope requestScope, PatchAction action) { try { JsonApiDocument value = requestScope.getMapper().readJsonApiPatchExtValue(patchVal); + + if (!path.contains("relationships")) { // Reserved + Data data = value.getData(); + Collection resources = data.get(); + // Defer relationship updating until the end + getSingleResource(resources).setRelationships(null); + // Reparse since we mangle it first + action.doc = requestScope.getMapper().readJsonApiPatchExtValue(patchVal); + action.path = path; + action.isPostProcessing = true; + } // Defer relationship updating until the end PatchVisitor visitor = new PatchVisitor(new PatchRequestScope(path, value, requestScope)); return visitor.visit(JsonApiParser.parse(path)); @@ -244,7 +265,7 @@ private Supplier> handleReplaceOp( /** * Remove data via patch extension. */ - private Supplier> handleRemoveOp(String path, + private Supplier> handleRemoveOp(String path, JsonNode patchValue, PatchRequestScope requestScope) { try { @@ -368,13 +389,20 @@ private static JsonNode toErrorNode(String detail, Integer status) { /** * Merge response documents to create final response. */ - private static JsonNode mergeResponse(List>> results) { + private static JsonNode mergeResponse( + List>> results, + JsonApiMapper mapper + ) { ArrayNode list = JsonNodeFactory.instance.arrayNode(); - for (Supplier> result : results) { - JsonNode node = result.get().getRight(); - if (node == null || node instanceof NullNode) { + for (Supplier> result : results) { + JsonNode node; + JsonApiDocument document = result.get().getRight(); + if (document == null) { node = JsonNodeFactory.instance.objectNode().set("data", null); + } else { + node = mapper.toJsonObject(document); } + list.add(node); } return list; diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java index 3e69b5454c..2cd328c656 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/models/Resource.java @@ -33,12 +33,15 @@ */ @ToString public class Resource { + + //Doesn't work currently - https://github.com/FasterXML/jackson-databind/issues/230 + @JsonProperty(required = true) private String type; private String id; private Map attributes; private Map relationships; private Map links; - private Map meta; + private Meta meta; public Resource(String type, String id) { this.type = type; @@ -53,7 +56,7 @@ public Resource(@JsonProperty("type") String type, @JsonProperty("attributes") Map attributes, @JsonProperty("relationships") Map relationships, @JsonProperty("links") Map links, - @JsonProperty("meta") Map meta) { + @JsonProperty("meta") Meta meta) { this.type = type; this.id = id; this.attributes = attributes; @@ -98,11 +101,11 @@ public String getType() { } @JsonInclude(JsonInclude.Include.NON_NULL) - public Map getMeta() { + public Meta getMeta() { return meta; } - public void setMeta(Map meta) { + public void setMeta(Meta meta) { this.meta = meta; } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/BaseVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/BaseVisitor.java index 40a091885a..3199c68197 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/BaseVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/BaseVisitor.java @@ -20,9 +20,9 @@ import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionRelationshipContext; import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionSubCollectionContext; import com.yahoo.elide.generated.parsers.CoreParser.TermContext; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.jsonapi.parser.state.StartState; import com.yahoo.elide.jsonapi.parser.state.StateContext; -import com.fasterxml.jackson.databind.JsonNode; import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; @@ -30,7 +30,7 @@ /** * Base request handler. */ -public abstract class BaseVisitor extends CoreBaseVisitor>> { +public abstract class BaseVisitor extends CoreBaseVisitor>> { protected final StateContext state; @@ -43,76 +43,90 @@ public StateContext getState() { } @Override - public Supplier> visitStart(StartContext ctx) { + public Supplier> visitStart(StartContext ctx) { return super.visitStart(ctx); } @Override - public Supplier> visitRootCollectionLoadEntities(RootCollectionLoadEntitiesContext ctx) { + public Supplier> visitRootCollectionLoadEntities( + RootCollectionLoadEntitiesContext ctx + ) { state.handle(ctx); return super.visitRootCollectionLoadEntities(ctx); } @Override - public Supplier> visitRootCollectionLoadEntity(RootCollectionLoadEntityContext ctx) { + public Supplier> visitRootCollectionLoadEntity( + RootCollectionLoadEntityContext ctx + ) { state.handle(ctx); return super.visitRootCollectionLoadEntity(ctx); } @Override - public Supplier> visitRootCollectionSubCollection(RootCollectionSubCollectionContext ctx) { + public Supplier> visitRootCollectionSubCollection( + RootCollectionSubCollectionContext ctx + ) { state.handle(ctx); return super.visitRootCollectionSubCollection(ctx); } @Override - public Supplier> + public Supplier> visitRootCollectionRelationship(RootCollectionRelationshipContext ctx) { state.handle(ctx); return super.visitRootCollectionRelationship(ctx); } @Override - public Supplier> visitEntity(EntityContext ctx) { + public Supplier> visitEntity(EntityContext ctx) { return super.visitEntity(ctx); } @Override - public Supplier> visitSubCollectionReadCollection(SubCollectionReadCollectionContext ctx) { + public Supplier> visitSubCollectionReadCollection( + SubCollectionReadCollectionContext ctx + ) { state.handle(ctx); return super.visitSubCollectionReadCollection(ctx); } @Override - public Supplier> visitSubCollectionReadEntity(SubCollectionReadEntityContext ctx) { + public Supplier> visitSubCollectionReadEntity( + SubCollectionReadEntityContext ctx + ) { state.handle(ctx); return super.visitSubCollectionReadEntity(ctx); } @Override - public Supplier> visitSubCollectionSubCollection(SubCollectionSubCollectionContext ctx) { + public Supplier> visitSubCollectionSubCollection( + SubCollectionSubCollectionContext ctx + ) { state.handle(ctx); return super.visitSubCollectionSubCollection(ctx); } @Override - public Supplier> visitSubCollectionRelationship(SubCollectionRelationshipContext ctx) { + public Supplier> visitSubCollectionRelationship( + SubCollectionRelationshipContext ctx + ) { state.handle(ctx); return super.visitSubCollectionRelationship(ctx); } @Override - public Supplier> visitQuery(QueryContext ctx) { + public Supplier> visitQuery(QueryContext ctx) { return super.visitQuery(ctx); } @Override - public Supplier> visitTerm(TermContext ctx) { + public Supplier> visitTerm(TermContext ctx) { return super.visitTerm(ctx); } @Override - public Supplier> visitId(IdContext ctx) { + public Supplier> visitId(IdContext ctx) { return super.visitId(ctx); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/DeleteVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/DeleteVisitor.java index 681e8565ef..26d432e142 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/DeleteVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/DeleteVisitor.java @@ -7,7 +7,7 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.generated.parsers.CoreParser.QueryContext; -import com.fasterxml.jackson.databind.JsonNode; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; @@ -27,7 +27,7 @@ public DeleteVisitor(RequestScope requestScope) { } @Override - public Supplier> visitQuery(QueryContext ctx) { + public Supplier> visitQuery(QueryContext ctx) { return state.handleDelete(); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/GetVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/GetVisitor.java index f5bb77f122..d58e13441a 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/GetVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/GetVisitor.java @@ -7,7 +7,7 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.generated.parsers.CoreParser.QueryContext; -import com.fasterxml.jackson.databind.JsonNode; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; @@ -27,7 +27,7 @@ public GetVisitor(RequestScope requestScope) { } @Override - public Supplier> visitQuery(QueryContext ctx) { + public Supplier> visitQuery(QueryContext ctx) { return state.handleGet(); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/JsonApiParser.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/JsonApiParser.java index 7be3c7ecb2..3084a8fb3e 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/JsonApiParser.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/JsonApiParser.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PatchVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PatchVisitor.java index 7b79df4b15..5250ca75b9 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PatchVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PatchVisitor.java @@ -7,7 +7,7 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.generated.parsers.CoreParser.QueryContext; -import com.fasterxml.jackson.databind.JsonNode; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; @@ -27,7 +27,7 @@ public PatchVisitor(RequestScope requestScope) { } @Override - public Supplier> visitQuery(QueryContext ctx) { + public Supplier> visitQuery(QueryContext ctx) { return state.handlePatch(); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PostVisitor.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PostVisitor.java index d909199192..0f5a77e09b 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PostVisitor.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/PostVisitor.java @@ -7,7 +7,7 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.generated.parsers.CoreParser.QueryContext; -import com.fasterxml.jackson.databind.JsonNode; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; @@ -27,7 +27,7 @@ public PostVisitor(RequestScope requestScope) { } @Override - public Supplier> visitQuery(QueryContext ctx) { + public Supplier> visitQuery(QueryContext ctx) { return state.handlePost(); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/BaseState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/BaseState.java index 1ff16b504f..560679797b 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/BaseState.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/BaseState.java @@ -19,10 +19,10 @@ import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionSubCollectionContext; import com.yahoo.elide.jsonapi.document.processors.DocumentProcessor; import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; +import com.yahoo.elide.jsonapi.document.processors.PopulateMetaProcessor; import com.yahoo.elide.jsonapi.models.Data; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.jsonapi.models.Resource; -import com.fasterxml.jackson.databind.JsonNode; import org.apache.commons.lang3.tuple.Pair; import java.util.function.Supplier; @@ -122,7 +122,7 @@ public void handle(StateContext state, SubCollectionRelationshipContext ctx) { * @return the supplier * @throws HttpStatusException the http status exception */ - public Supplier> handleGet(StateContext state) throws HttpStatusException { + public Supplier> handleGet(StateContext state) throws HttpStatusException { throw new UnsupportedOperationException(this.getClass().toString()); } @@ -133,7 +133,7 @@ public Supplier> handleGet(StateContext state) throws Ht * @return the supplier * @throws HttpStatusException the http status exception */ - public Supplier> handlePatch(StateContext state) throws HttpStatusException { + public Supplier> handlePatch(StateContext state) throws HttpStatusException { throw new UnsupportedOperationException(this.getClass().toString()); } @@ -144,7 +144,7 @@ public Supplier> handlePatch(StateContext state) throws * @return the supplier * @throws HttpStatusException the http status exception */ - public Supplier> handlePost(StateContext state) throws HttpStatusException { + public Supplier> handlePost(StateContext state) throws HttpStatusException { throw new UnsupportedOperationException(this.getClass().toString()); } @@ -155,7 +155,7 @@ public Supplier> handlePost(StateContext state) throws H * @return the supplier * @throws HttpStatusException the http status exception */ - public Supplier> handleDelete(StateContext state) throws HttpStatusException { + public Supplier> handleDelete(StateContext state) throws HttpStatusException { throw new UnsupportedOperationException(this.getClass().toString()); } @@ -166,7 +166,7 @@ public Supplier> handleDelete(StateContext state) throws * @param stateContext a state that contains reference to request scope where we can get status code for update * @return a supplier of PATH response */ - protected static Supplier> constructPatchResponse( + protected static Supplier> constructPatchResponse( PersistentResource record, StateContext stateContext) { RequestScope requestScope = stateContext.getRequestScope(); @@ -177,7 +177,7 @@ protected static Supplier> constructPatchResponse( ); } - protected static JsonNode getResponseBody(PersistentResource resource, RequestScope requestScope) { + protected static JsonApiDocument getResponseBody(PersistentResource resource, RequestScope requestScope) { MultivaluedMap queryParams = requestScope.getQueryParams(); JsonApiDocument jsonApiDocument = new JsonApiDocument(); @@ -187,8 +187,11 @@ protected static JsonNode getResponseBody(PersistentResource resource, RequestSc //TODO Iterate over set of document processors DocumentProcessor includedProcessor = new IncludedProcessor(); - includedProcessor.execute(jsonApiDocument, resource, queryParams); + includedProcessor.execute(jsonApiDocument, requestScope, resource, queryParams); - return requestScope.getMapper().toJsonObject(jsonApiDocument); + PopulateMetaProcessor metaProcessor = new PopulateMetaProcessor(); + metaProcessor.execute(jsonApiDocument, requestScope, resource, queryParams); + + return jsonApiDocument; } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java index 060c859d2f..47afbe67c8 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/CollectionTerminalState.java @@ -22,13 +22,13 @@ import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.document.processors.DocumentProcessor; import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; +import com.yahoo.elide.jsonapi.document.processors.PopulateMetaProcessor; import com.yahoo.elide.jsonapi.models.Data; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.jsonapi.models.Meta; import com.yahoo.elide.jsonapi.models.Relationship; import com.yahoo.elide.jsonapi.models.Resource; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.Preconditions; import org.apache.commons.collections4.IterableUtils; import org.apache.commons.lang3.tuple.Pair; @@ -67,12 +67,12 @@ public CollectionTerminalState(Type entityClass, Optional } @Override - public Supplier> handleGet(StateContext state) { + public Supplier> handleGet(StateContext state) { JsonApiDocument jsonApiDocument = new JsonApiDocument(); RequestScope requestScope = state.getRequestScope(); MultivaluedMap queryParams = requestScope.getQueryParams(); - Set collection = + LinkedHashSet collection = getResourceCollection(requestScope).toList(LinkedHashSet::new).blockingGet(); // Set data @@ -80,7 +80,7 @@ public Supplier> handleGet(StateContext state) { // Run include processor DocumentProcessor includedProcessor = new IncludedProcessor(); - includedProcessor.execute(jsonApiDocument, collection, queryParams); + includedProcessor.execute(jsonApiDocument, requestScope, collection, queryParams); Pagination pagination = parentProjection.getPagination(); if (parent.isPresent()) { @@ -110,13 +110,14 @@ public Supplier> handleGet(StateContext state) { jsonApiDocument.setMeta(meta); } - JsonNode responseBody = requestScope.getMapper().toJsonObject(jsonApiDocument); + PopulateMetaProcessor metaProcessor = new PopulateMetaProcessor(); + metaProcessor.execute(jsonApiDocument, requestScope, collection, queryParams); - return () -> Pair.of(HttpStatus.SC_OK, responseBody); + return () -> Pair.of(HttpStatus.SC_OK, jsonApiDocument); } @Override - public Supplier> handlePost(StateContext state) { + public Supplier> handlePost(StateContext state) { RequestScope requestScope = state.getRequestScope(); JsonApiMapper mapper = requestScope.getMapper(); @@ -125,8 +126,11 @@ public Supplier> handlePost(StateContext state) { return () -> { JsonApiDocument returnDoc = new JsonApiDocument(); returnDoc.setData(new Data<>(newObject.toResource())); - JsonNode responseBody = mapper.getObjectMapper().convertValue(returnDoc, JsonNode.class); - return Pair.of(HttpStatus.SC_CREATED, responseBody); + + PopulateMetaProcessor metaProcessor = new PopulateMetaProcessor(); + metaProcessor.execute(returnDoc, requestScope, newObject, requestScope.getQueryParams()); + + return Pair.of(HttpStatus.SC_CREATED, returnDoc); }; } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordTerminalState.java index 5bf89ad565..c2ee3cd2cf 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordTerminalState.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RecordTerminalState.java @@ -14,7 +14,6 @@ import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.jsonapi.models.Relationship; import com.yahoo.elide.jsonapi.models.Resource; -import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.lang3.tuple.Pair; import lombok.ToString; @@ -43,20 +42,20 @@ public RecordTerminalState(PersistentResource record, CollectionTerminalState co } @Override - public Supplier> handleGet(StateContext state) { + public Supplier> handleGet(StateContext state) { ObjectMapper mapper = state.getRequestScope().getMapper().getObjectMapper(); return () -> Pair.of(HttpStatus.SC_OK, getResponseBody(record, state.getRequestScope())); } @Override - public Supplier> handlePost(StateContext state) { + public Supplier> handlePost(StateContext state) { return collectionTerminalState .orElseThrow(() -> new InvalidOperationException("Cannot POST to a record.")) .handlePost(state); } @Override - public Supplier> handlePatch(StateContext state) { + public Supplier> handlePatch(StateContext state) { JsonApiDocument jsonApiDocument = state.getJsonApiDocument(); Data data = jsonApiDocument.getData(); @@ -79,7 +78,7 @@ public Supplier> handlePatch(StateContext state) { } @Override - public Supplier> handleDelete(StateContext state) { + public Supplier> handleDelete(StateContext state) { record.deleteResource(); return () -> Pair.of(HttpStatus.SC_NO_CONTENT, null); } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RelationshipTerminalState.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RelationshipTerminalState.java index c9108aec6a..5ee3c0e33b 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RelationshipTerminalState.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/RelationshipTerminalState.java @@ -13,14 +13,13 @@ import com.yahoo.elide.core.exceptions.HttpStatus; import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; import com.yahoo.elide.core.request.EntityProjection; -import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.document.processors.DocumentProcessor; import com.yahoo.elide.jsonapi.document.processors.IncludedProcessor; +import com.yahoo.elide.jsonapi.document.processors.PopulateMetaProcessor; import com.yahoo.elide.jsonapi.models.Data; import com.yahoo.elide.jsonapi.models.JsonApiDocument; import com.yahoo.elide.jsonapi.models.Relationship; import com.yahoo.elide.jsonapi.models.Resource; -import com.fasterxml.jackson.databind.JsonNode; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.tuple.Pair; @@ -52,10 +51,9 @@ public RelationshipTerminalState(PersistentResource record, String relationshipN } @Override - public Supplier> handleGet(StateContext state) { + public Supplier> handleGet(StateContext state) { JsonApiDocument doc = new JsonApiDocument(); RequestScope requestScope = state.getRequestScope(); - JsonApiMapper mapper = requestScope.getMapper(); MultivaluedMap queryParams = requestScope.getQueryParams(); Map relationships = record.toResource(parentProjection).getRelationships(); @@ -70,9 +68,9 @@ public Supplier> handleGet(StateContext state) { // Run include processor DocumentProcessor includedProcessor = new IncludedProcessor(); - includedProcessor.execute(doc, record, queryParams); + includedProcessor.execute(doc, requestScope, record, queryParams); - return () -> Pair.of(HttpStatus.SC_OK, mapper.toJsonObject(doc)); + return () -> Pair.of(HttpStatus.SC_OK, doc); } // Handle no data for relationship @@ -83,21 +81,25 @@ public Supplier> handleGet(StateContext state) { } else { throw new IllegalStateException("Failed to GET a relationship; relationship is neither toMany nor toOne"); } - return () -> Pair.of(HttpStatus.SC_OK, mapper.toJsonObject(doc)); + + PopulateMetaProcessor metaProcessor = new PopulateMetaProcessor(); + metaProcessor.execute(doc, requestScope, record, queryParams); + + return () -> Pair.of(HttpStatus.SC_OK, doc); } @Override - public Supplier> handlePatch(StateContext state) { + public Supplier> handlePatch(StateContext state) { return handleRequest(state, this::patch); } @Override - public Supplier> handlePost(StateContext state) { + public Supplier> handlePost(StateContext state) { return handleRequest(state, this::post); } @Override - public Supplier> handleDelete(StateContext state) { + public Supplier> handleDelete(StateContext state) { return handleRequest(state, this::delete); } @@ -105,7 +107,7 @@ public Supplier> handleDelete(StateContext state) { * Base on the JSON API docs relationship updates MUST return 204 unless the server has made additional modification * to the relationship. http://jsonapi.org/format/#crud-updating-relationship-responses */ - private Supplier> handleRequest(StateContext state, + private Supplier> handleRequest(StateContext state, BiPredicate, RequestScope> handler) { Data data = state.getJsonApiDocument().getData(); handler.test(data, state.getRequestScope()); diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StateContext.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StateContext.java index cd3e5d7696..a97735547c 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StateContext.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/parser/state/StateContext.java @@ -15,7 +15,6 @@ import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionRelationshipContext; import com.yahoo.elide.generated.parsers.CoreParser.SubCollectionSubCollectionContext; import com.yahoo.elide.jsonapi.models.JsonApiDocument; -import com.fasterxml.jackson.databind.JsonNode; import org.apache.commons.lang3.tuple.Pair; import lombok.extern.slf4j.Slf4j; @@ -104,19 +103,19 @@ public void handle(SubCollectionRelationshipContext ctx) { currentState.handle(this, ctx); } - public Supplier> handleGet() { + public Supplier> handleGet() { return currentState.handleGet(this); } - public Supplier> handlePatch() { + public Supplier> handlePatch() { return currentState.handlePatch(this); } - public Supplier> handlePost() { + public Supplier> handlePost() { return currentState.handlePost(this); } - public Supplier> handleDelete() { + public Supplier> handleDelete() { return currentState.handleDelete(this); } } diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java index 45833d2850..406e96d938 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/resources/JsonApiEndpoint.java @@ -43,11 +43,13 @@ @Path("/") public class JsonApiEndpoint { protected final Elide elide; + protected final HeaderUtils.HeaderProcessor headerProcessor; @Inject public JsonApiEndpoint( @Named("elide") Elide elide) { this.elide = elide; + this.headerProcessor = elide.getElideSettings().getHeaderProcessor(); } /** @@ -71,8 +73,7 @@ public Response post( String jsonapiDocument) { MultivaluedMap queryParams = uriInfo.getQueryParameters(); String apiVersion = HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); - Map> requestHeaders = - HeaderUtils.lowercaseAndRemoveAuthHeaders(headers.getRequestHeaders()); + Map> requestHeaders = headerProcessor.process(headers.getRequestHeaders()); User user = new SecurityContextUser(securityContext); return build(elide.post(getBaseUrlEndpoint(uriInfo), path, jsonapiDocument, queryParams, requestHeaders, user, apiVersion, UUID.randomUUID())); @@ -96,8 +97,7 @@ public Response get( @Context SecurityContext securityContext) { MultivaluedMap queryParams = uriInfo.getQueryParameters(); String apiVersion = HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); - Map> requestHeaders = - HeaderUtils.lowercaseAndRemoveAuthHeaders(headers.getRequestHeaders()); + Map> requestHeaders = headerProcessor.process(headers.getRequestHeaders()); User user = new SecurityContextUser(securityContext); return build(elide.get(getBaseUrlEndpoint(uriInfo), path, queryParams, @@ -129,8 +129,7 @@ public Response patch( String jsonapiDocument) { MultivaluedMap queryParams = uriInfo.getQueryParameters(); String apiVersion = HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); - Map> requestHeaders = - HeaderUtils.lowercaseAndRemoveAuthHeaders(headers.getRequestHeaders()); + Map> requestHeaders = headerProcessor.process(headers.getRequestHeaders()); User user = new SecurityContextUser(securityContext); return build(elide.patch(getBaseUrlEndpoint(uriInfo), contentType, accept, path, jsonapiDocument, queryParams, requestHeaders, user, apiVersion, UUID.randomUUID())); @@ -158,8 +157,7 @@ public Response delete( MultivaluedMap queryParams = uriInfo.getQueryParameters(); String apiVersion = HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); - Map> requestHeaders = - HeaderUtils.lowercaseAndRemoveAuthHeaders(headers.getRequestHeaders()); + Map> requestHeaders = headerProcessor.process(headers.getRequestHeaders()); User user = new SecurityContextUser(securityContext); return build(elide.delete(getBaseUrlEndpoint(uriInfo), path, jsonApiDocument, queryParams, requestHeaders, user, apiVersion, UUID.randomUUID())); diff --git a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataDeserializer.java b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataDeserializer.java index 5ae144af7b..b178dcdb41 100644 --- a/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataDeserializer.java +++ b/elide-core/src/main/java/com/yahoo/elide/jsonapi/serialization/DataDeserializer.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.MappingJsonFactory; import com.fasterxml.jackson.databind.ObjectMapper; @@ -32,11 +33,19 @@ public Data deserialize(JsonParser jsonParser, DeserializationContext List resources = new ArrayList<>(); for (JsonNode n : node) { Resource r = MAPPER.convertValue(n, Resource.class); + validateResource(jsonParser, r); resources.add(r); } return new Data<>(resources); } Resource resource = MAPPER.convertValue(node, Resource.class); + validateResource(jsonParser, resource); return new Data<>(resource); } + + private void validateResource(JsonParser jsonParser, Resource resource) throws IOException { + if (resource.getType() == null || resource.getType().isEmpty()) { + throw JsonMappingException.from(jsonParser, "Resource 'type' field is missing or empty."); + } + } } diff --git a/elide-core/src/main/java/com/yahoo/elide/utils/HeaderUtils.java b/elide-core/src/main/java/com/yahoo/elide/utils/HeaderUtils.java index b30b1f6c6b..7132b65f66 100644 --- a/elide-core/src/main/java/com/yahoo/elide/utils/HeaderUtils.java +++ b/elide-core/src/main/java/com/yahoo/elide/utils/HeaderUtils.java @@ -19,6 +19,11 @@ */ public class HeaderUtils { + @FunctionalInterface + public interface HeaderProcessor { + Map> process(Map> headers); + } + /** * Resolve value of api version from request headers. * @param headers HttpHeaders diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PathTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PathTest.java new file mode 100644 index 0000000000..a85252ac35 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/PathTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import example.Book; +import example.Editor; +import org.junit.jupiter.api.Test; + +import java.util.List; + +public class PathTest { + + @Test + public void testIsComputed() { + + EntityDictionary dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); + dictionary.bindEntity(Editor.class); + + Path computedRelationshipPath = new Path(List.of( + new Path.PathElement(Book.class, Editor.class, "editor"), + new Path.PathElement(Editor.class, String.class, "firstName") + )); + + Path computedAttributePath = new Path(List.of( + new Path.PathElement(Editor.class, String.class, "fullName") + )); + + Path attributePath = new Path(List.of( + new Path.PathElement(Book.class, String.class, "title") + )); + + assertTrue(computedRelationshipPath.isComputed(dictionary)); + assertTrue(computedAttributePath.isComputed(dictionary)); + assertFalse(attributePath.isComputed(dictionary)); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PersistenceResourceTestSetup.java b/elide-core/src/test/java/com/yahoo/elide/core/PersistenceResourceTestSetup.java index 59a7445a6c..9c08851537 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/PersistenceResourceTestSetup.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/PersistenceResourceTestSetup.java @@ -12,12 +12,14 @@ import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.DeletePermission; import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.annotation.UpdatePermission; import com.yahoo.elide.core.audit.AuditLogger; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.dictionary.TestDictionary; +import com.yahoo.elide.core.lifecycle.LifeCycleHook; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.security.ChangeSpec; import com.yahoo.elide.core.security.TestUser; @@ -75,6 +77,8 @@ public class PersistenceResourceTestSetup extends PersistentResource { protected final ElideSettings elideSettings; + protected static LifeCycleHook bookUpdatePrice = mock(LifeCycleHook.class); + protected static EntityDictionary initDictionary() { EntityDictionary dictionary = TestDictionary.getTestDictionary(); @@ -107,6 +111,11 @@ protected static EntityDictionary initDictionary() { dictionary.bindEntity(StrictNoTransfer.class); dictionary.bindEntity(Untransferable.class); dictionary.bindEntity(Company.class); + + dictionary.bindTrigger(Book.class, "price", + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.PRESECURITY, bookUpdatePrice); + return dictionary; } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceNoopUpdateTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceNoopUpdateTest.java index e284028b85..c436b8ff08 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceNoopUpdateTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceNoopUpdateTest.java @@ -13,6 +13,7 @@ import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.security.TestUser; import com.yahoo.elide.core.security.User; @@ -34,6 +35,7 @@ public class PersistentResourceNoopUpdateTest extends PersistenceResourceTestSet initDictionary(); reset(goodUserScope.getTransaction()); } + @Test public void testNOOPToOneAddRelation() { FunWithPermissions fun = new FunWithPermissions(); @@ -45,6 +47,9 @@ public void testNOOPToOneAddRelation() { RequestScope goodScope = new RequestScope(null, null, NO_VERSION, null, tx, goodUser, null, null, UUID.randomUUID(), elideSettings); PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); PersistentResource childResource = new PersistentResource<>(child, "1", goodScope); + + when(tx.getToOneRelation(eq(tx), eq(fun), any(), any())).thenReturn(child); + //We do not want the update to one method to be called when we add the existing entity to the relation funResource.addRelation("relation3", childResource); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java index d5c003fd16..109f9b6e15 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/PersistentResourceTest.java @@ -5,6 +5,8 @@ */ package com.yahoo.elide.core; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRESECURITY; import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -26,6 +28,7 @@ import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.core.audit.LogMessage; import com.yahoo.elide.core.audit.TestAuditLogger; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; import com.yahoo.elide.core.exceptions.InvalidAttributeException; @@ -34,6 +37,7 @@ import com.yahoo.elide.core.filter.Operator; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.filter.predicates.FilterPredicate; +import com.yahoo.elide.core.lifecycle.CRUDEvent; import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.security.ChangeSpec; @@ -71,6 +75,7 @@ import example.NoShareEntity; import example.NoUpdateEntity; import example.Parent; +import example.Price; import example.Right; import example.Shape; import example.nontransferable.ContainerWithPackageShare; @@ -89,10 +94,12 @@ import io.reactivex.Observable; import nocreate.NoCreateEntity; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Currency; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; @@ -176,7 +183,7 @@ public void testUpdateToOneRelationHookInClearRelation() { Child child1 = newChild(1); fun.setRelation3(child1); - when(tx.getRelation(any(), eq(fun), any(), any())).thenReturn(child1); + when(tx.getToOneRelation(any(), eq(fun), any(), any())).thenReturn(child1); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); @@ -229,7 +236,8 @@ public void testUpdateToManyRelationHookInClearRelationBidirection() { child1.setParents(Sets.newHashSet(parent)); child2.setParents(Sets.newHashSet(parent)); - when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(children); + when(tx.getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, "3", goodScope); @@ -254,7 +262,8 @@ public void testUpdateToManyRelationHookInUpdateRelationBidirection() { child1.setParents(Sets.newHashSet(parent)); child2.setParents(Sets.newHashSet(parent)); - when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(children); + when(tx.getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource parentResource = new PersistentResource<>(parent, "3", goodScope); @@ -597,7 +606,7 @@ public void testSuccessfulOneToOneRelationshipAdd() throws Exception { goodScope.saveOrCreateObjects(); verify(tx, times(1)).save(left, goodScope); verify(tx, times(1)).save(right, goodScope); - verify(tx, times(1)).getRelation(tx, left, getRelationship(ClassType.of(Right.class), "one2one"), goodScope); + verify(tx, times(1)).getToOneRelation(tx, left, getRelationship(ClassType.of(Right.class), "one2one"), goodScope); assertTrue(updated, "The one-2-one relationship should be added."); assertEquals(3, left.getOne2one().getId(), "The correct object was set in the one-2-one relationship"); @@ -674,7 +683,7 @@ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { parent.setChildren(allChildren); parent.setSpouses(Sets.newHashSet()); - when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(allChildren); + when(tx.getToManyRelation(any(), eq(parent), any(), any())).thenReturn(new DataStoreIterableBuilder(allChildren).build()); PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); @@ -718,6 +727,160 @@ public void testSuccessfulManyToManyRelationshipUpdate() throws Exception { */ } + @Test + /* + * The following are ids for a hypothetical relationship. + * GIVEN: + * all (all the ids in the DB) = 1,2,3,4,5 + * mine (everything the current user has access to) = 1,2,3 + * requested (what the user wants to change to) = 1,2,3 + * THEN: + * deleted (what gets removed from the DB) = nothing + * final (what get stored in the relationship) = 1,2,3,4,5 + * BECAUSE: + * notMine = all - mine + * updated = (requested UNION mine) - (requested INTERSECT mine) + * deleted = (mine - requested) + * final = (notMine) UNION requested + */ + public void testSuccessfulManyToManyRelationshipNoopUpdate() throws Exception { + Parent parent = new Parent(); + RequestScope goodScope = buildRequestScope(tx, goodUser); + + Child child1 = newChild(1); + Child child2 = newChild(2); + Child child3 = newChild(3); + Child child4 = newChild(-4); //Not accessible to goodUser + Child child5 = newChild(-5); //Not accessible to goodUser + + //All = (1,2,3,4,5) + //Mine = (1,2,3) + Set allChildren = new HashSet<>(); + allChildren.add(child1); + allChildren.add(child2); + allChildren.add(child3); + allChildren.add(child4); + allChildren.add(child5); + parent.setChildren(allChildren); + parent.setSpouses(Sets.newHashSet()); + + when(tx.getToManyRelation(any(), eq(parent), any(), any())).thenReturn(new DataStoreIterableBuilder(allChildren).build()); + + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); + + //Requested = (1,2,3) + List idList = new ArrayList<>(); + idList.add(new ResourceIdentifier("child", "3").castToResource()); + idList.add(new ResourceIdentifier("child", "2").castToResource()); + idList.add(new ResourceIdentifier("child", "1").castToResource()); + Relationship ids = new Relationship(null, new Data<>(idList)); + + when(tx.loadObject(any(), eq(1L), any())).thenReturn(child1); + when(tx.loadObject(any(), eq(2L), any())).thenReturn(child2); + when(tx.loadObject(any(), eq(3L), any())).thenReturn(child3); + when(tx.loadObject(any(), eq(-4L), any())).thenReturn(child4); + when(tx.loadObject(any(), eq(-5L), any())).thenReturn(child5); + + //Final set after operation = (1,2,3,4,5) + Set expected = new HashSet<>(); + expected.add(child1); + expected.add(child2); + expected.add(child3); + expected.add(child4); + expected.add(child5); + + boolean updated = parentResource.updateRelation("children", ids.toPersistentResources(goodScope)); + + goodScope.saveOrCreateObjects(); + verify(tx, never()).save(parent, goodScope); + verify(tx, never()).save(child1, goodScope); + verify(tx, never()).save(child2, goodScope); + verify(tx, never()).save(child4, goodScope); + verify(tx, never()).save(child5, goodScope); + verify(tx, never()).save(child3, goodScope); + + assertFalse(updated, "Many-2-many relationship should not be updated."); + assertTrue(parent.getChildren().containsAll(expected), "All expected members were updated"); + assertTrue(expected.containsAll(parent.getChildren()), "All expected members were updated"); + + /* + * No tests for reference integrity since the parent is the owner and + * this is a many to many relationship. + */ + } + + @Test + /* + * The following are ids for a hypothetical relationship. + * GIVEN: + * all (all the ids in the DB) = null + * mine (everything the current user has access to) = null + * requested (what the user wants to change to) = 1,2,3 + * THEN: + * deleted (what gets removed from the DB) = nothing + * final (what get stored in the relationship) = 1,2,3 + * BECAUSE: + * notMine = all - mine + * updated = (requested UNION mine) - (requested INTERSECT mine) + * deleted = (mine - requested) + * final = (notMine) UNION requested + */ + public void testSuccessfulManyToManyRelationshipNullUpdate() throws Exception { + Parent parent = new Parent(); + RequestScope goodScope = buildRequestScope(tx, goodUser); + + Child child1 = newChild(1); + Child child2 = newChild(2); + Child child3 = newChild(3); + + //All = null + //Mine = null + Set allChildren = new HashSet<>(); + allChildren.add(child1); + allChildren.add(child2); + allChildren.add(child3); + parent.setChildren(null); + parent.setSpouses(Sets.newHashSet()); + + when(tx.getToManyRelation(any(), eq(parent), any(), any())).thenReturn(null); + + PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); + + //Requested = (1,2,3) + List idList = new ArrayList<>(); + idList.add(new ResourceIdentifier("child", "3").castToResource()); + idList.add(new ResourceIdentifier("child", "2").castToResource()); + idList.add(new ResourceIdentifier("child", "1").castToResource()); + Relationship ids = new Relationship(null, new Data<>(idList)); + + when(tx.loadObject(any(), eq(1L), any())).thenReturn(child1); + when(tx.loadObject(any(), eq(2L), any())).thenReturn(child2); + when(tx.loadObject(any(), eq(3L), any())).thenReturn(child3); + + //Final set after operation = (1,2,3) + Set expected = new HashSet<>(); + expected.add(child1); + expected.add(child2); + expected.add(child3); + + boolean updated = parentResource.updateRelation("children", ids.toPersistentResources(goodScope)); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(parent, goodScope); + verify(tx, times(1)).save(child1, goodScope); + verify(tx, times(1)).save(child2, goodScope); + verify(tx, times(1)).save(child3, goodScope); + + assertTrue(updated, "Many-2-many relationship should be updated."); + assertTrue(parent.getChildren().containsAll(expected), "All expected members were updated"); + assertTrue(expected.containsAll(parent.getChildren()), "All expected members were updated"); + + /* + * No tests for reference integrity since the parent is the owner and + * this is a many to many relationship. + */ + } + /** * Verify that Relationship toMany cannot contain null resources, but toOne can. * @@ -826,7 +989,8 @@ public void testGetRelationSuccess() { RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); PersistentResource funResource = new PersistentResource<>(fun, "3", scope); - when(scope.getTransaction().getRelation(any(), eq(fun), any(), any())).thenReturn(children); + when(scope.getTransaction().getToManyRelation(any(), eq(fun), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); Set results = getRelation(funResource, "relation2"); @@ -846,7 +1010,8 @@ public void testGetRelationFilteredSuccess() { RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); PersistentResource funResource = new PersistentResource<>(fun, "3", scope); - when(scope.getTransaction().getRelation(any(), eq(fun), any(), any())).thenReturn(children); + when(scope.getTransaction().getToManyRelation(any(), eq(fun), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); Set results = getRelation(funResource, "relation2"); @@ -861,7 +1026,8 @@ public void testGetRelationWithPredicateSuccess() { Child child3 = newChild(3, "chris smith"); parent.setChildren(Sets.newHashSet(child1, child2, child3)); - when(tx.getRelation(eq(tx), any(), any(), any())).thenReturn(Sets.newHashSet(child1)); + when(tx.getToManyRelation(eq(tx), any(), any(), any())) + .thenReturn(new DataStoreIterableBuilder(Sets.newHashSet(child1)).build()); MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.add("filter[child.name]", "paul john"); @@ -886,7 +1052,8 @@ public void testGetSingleRelationInMemory() { parent.setChildren(children); RequestScope scope = new TestRequestScope(tx, goodUser, dictionary); - when(scope.getTransaction().getRelation(any(), eq(parent), any(), any())).thenReturn(children); + when(scope.getTransaction().getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(children).build()); PersistentResource parentResource = new PersistentResource<>(parent, "1", scope); @@ -982,7 +1149,8 @@ public void testGetRelationByIdSuccess() { Child child3 = newChild(3); fun.setRelation2(Sets.newHashSet(child1, child2, child3)); - when(tx.getRelation(eq(tx), any(), any(), any())).thenReturn(Sets.newHashSet(child1)); + when(tx.getToManyRelation(eq(tx), any(), any(), any())) + .thenReturn(new DataStoreIterableBuilder(Sets.newHashSet(child1)).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); @@ -1004,7 +1172,8 @@ public void testGetRelationByInvalidId() { Child child3 = newChild(3); fun.setRelation2(Sets.newHashSet(child1, child2, child3)); - when(tx.getRelation(eq(tx), any(), any(), any())).thenReturn(Sets.newHashSet(child1)); + when(tx.getToManyRelation(eq(tx), any(), any(), any())) + .thenReturn(new DataStoreIterableBuilder(Sets.newHashSet(child1)).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource funResource = new PersistentResource<>(fun, "3", goodScope); @@ -1086,7 +1255,8 @@ void testDeleteResourceUpdateRelationshipSuccess() { assertFalse(parent.getChildren().isEmpty()); - when(tx.getRelation(any(), eq(child), any(), any())).thenReturn(parents); + when(tx.getToManyRelation(any(), eq(child), any(), any())) + .thenReturn(new DataStoreIterableBuilder(parents).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); @@ -1148,6 +1318,32 @@ void testAddRelationForbiddenByField() { assertThrows(ForbiddenAccessException.class, () -> funResource.addRelation("relation1", childResource)); } + @Test + void testAddRelationForbiddenByToManyExistingRelationship() { + FunWithPermissions fun = new FunWithPermissions(); + + Child child = newChild(1); + fun.setRelation1(Set.of(child)); + + RequestScope badScope = buildRequestScope(tx, badUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", badScope); + PersistentResource childResource = new PersistentResource<>(child, "1", badScope); + assertThrows(ForbiddenAccessException.class, () -> funResource.addRelation("relation1", childResource)); + } + + @Test + void testAddRelationForbiddenByToOneExistingRelationship() { + FunWithPermissions fun = new FunWithPermissions(); + + Child child = newChild(1); + fun.setRelation3(child); + + RequestScope badScope = buildRequestScope(tx, badUser); + PersistentResource funResource = new PersistentResource<>(fun, "3", badScope); + PersistentResource childResource = new PersistentResource<>(child, "1", badScope); + assertThrows(ForbiddenAccessException.class, () -> funResource.addRelation("relation3", childResource)); + } + @Test void testAddRelationForbiddenByEntity() { NoUpdateEntity noUpdate = new NoUpdateEntity(); @@ -1237,7 +1433,7 @@ public void testNoSaveNonModifications() { child.setReadNoAccess(secret); - when(tx.getRelation(any(), eq(fun), eq(com.yahoo.elide.core.request.Relationship.builder() + when(tx.getToOneRelation(any(), eq(fun), eq(com.yahoo.elide.core.request.Relationship.builder() .name("relation3") .alias("relation3") .projection(EntityProjection.builder() @@ -1245,23 +1441,23 @@ public void testNoSaveNonModifications() { .build()) .build()), any())).thenReturn(child); - when(tx.getRelation(any(), eq(fun), eq(com.yahoo.elide.core.request.Relationship.builder() + when(tx.getToManyRelation(any(), eq(fun), eq(com.yahoo.elide.core.request.Relationship.builder() .name("relation1") .alias("relation1") .projection(EntityProjection.builder() .type(Child.class) .build()) - .build()), any())).thenReturn(children1); + .build()), any())).thenReturn(new DataStoreIterableBuilder(children1).build()); - when(tx.getRelation(any(), eq(parent), eq(com.yahoo.elide.core.request.Relationship.builder() + when(tx.getToManyRelation(any(), eq(parent), eq(com.yahoo.elide.core.request.Relationship.builder() .name("children") .alias("children") .projection(EntityProjection.builder() .type(Child.class) .build()) - .build()), any())).thenReturn(children2); + .build()), any())).thenReturn(new DataStoreIterableBuilder(children2).build()); - when(tx.getRelation(any(), eq(child), eq(com.yahoo.elide.core.request.Relationship.builder() + when(tx.getToOneRelation(any(), eq(child), eq(com.yahoo.elide.core.request.Relationship.builder() .name("readNoAccess") .alias("readNoAccess") .projection(EntityProjection.builder() @@ -1373,7 +1569,8 @@ public void testClearToManyRelationSuccess() { Set parents = Sets.newHashSet(parent1, parent2, parent3); child.setParents(parents); - when(tx.getRelation(any(), eq(child), any(), any())).thenReturn(parents); + when(tx.getToManyRelation(any(), eq(child), any(), any())) + .thenReturn(new DataStoreIterableBuilder(parents).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); goodScope.setEntityProjection(EntityProjection.builder() @@ -1406,7 +1603,7 @@ public void testClearToOneRelationSuccess() { Child child = newChild(1); fun.setRelation3(child); - when(tx.getRelation(any(), eq(fun), any(), any())).thenReturn(child); + when(tx.getToOneRelation(any(), eq(fun), any(), any())).thenReturn(child); RequestScope goodScope = buildRequestScope(tx, goodUser); goodScope.setEntityProjection(EntityProjection.builder() @@ -1459,7 +1656,8 @@ public void testClearRelationFilteredByReadAccess() { parent.setChildren(allChildren); parent.setSpouses(Sets.newHashSet()); - when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(allChildren); + when(tx.getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(allChildren).build()); PersistentResource parentResource = new PersistentResource<>(parent, "1", goodScope); @@ -1548,7 +1746,8 @@ public void testClearRelationInvalidToManyUpdatePermission() { right1.setNoUpdate(Sets.newHashSet(left)); right2.setNoUpdate(Sets.newHashSet(left)); - when(tx.getRelation(any(), eq(left), any(), any())).thenReturn(noInverseUpdate); + when(tx.getToManyRelation(any(), eq(left), any(), any())) + .thenReturn(new DataStoreIterableBuilder(noInverseUpdate).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); goodScope.setEntityProjection(EntityProjection.builder() @@ -1576,7 +1775,7 @@ public void testClearRelationInvalidToOneDeletePermission() { noDelete.setId(1); left.setNoDeleteOne2One(noDelete); - when(tx.getRelation(any(), eq(left), any(), any())).thenReturn(noDelete); + when(tx.getToOneRelation(any(), eq(left), any(), any())).thenReturn(noDelete); RequestScope goodScope = buildRequestScope(tx, goodUser); goodScope.setEntityProjection(EntityProjection.builder() @@ -1634,6 +1833,84 @@ public void testUpdateComplexAttributeSuccess() { verify(tx, times(1)).save(company, goodScope); } + @Test + public void testUpdateComplexAttributeCloneWithHook() { + reset(bookUpdatePrice); + Book book = new Book(); + + Price originalPrice = new Price(); + originalPrice.setUnits(new BigDecimal(1.0)); + originalPrice.setCurrency(Currency.getInstance("USD")); + book.setPrice(originalPrice); + + Map newPrice = new HashMap<>(); + newPrice.put("units", new BigDecimal(2.0)); + newPrice.put("currency", Currency.getInstance("CNY")); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource bookResource = new PersistentResource<>(book, "1", goodScope); + bookResource.updateAttribute("price", newPrice); + + //check that original value was unmodified. + assertEquals(Currency.getInstance("USD"), originalPrice.getCurrency()); + assertEquals(new BigDecimal(1.0), originalPrice.getUnits()); + + //check that new value matches expected. + assertEquals(Currency.getInstance("CNY"), book.getPrice().getCurrency()); + assertEquals(new BigDecimal(2.0), book.getPrice().getUnits()); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(book, goodScope); + + ArgumentCaptor eventCapture = ArgumentCaptor.forClass(CRUDEvent.class); + verify(bookUpdatePrice, times(1)).execute(eq(UPDATE), eq(PRESECURITY), + eventCapture.capture()); + + assertEquals(originalPrice, eventCapture.getValue().getChanges().get().getOriginal()); + assertEquals(book.getPrice(), eventCapture.getValue().getChanges().get().getModified()); + } + + @Test + public void testUpdateNestedComplexAttributeClone() { + Company company = newCompany("abc"); + Address originalAddress = new Address(); + originalAddress.setStreet1("street1"); + originalAddress.setStreet2("street2"); + + GeoLocation originalGeo = new GeoLocation(); + originalGeo.setLatitude("1"); + originalGeo.setLongitude("2"); + originalAddress.setGeo(originalGeo); + + Map newAddress = new HashMap<>(); + newAddress.put("street1", "Elm"); + newAddress.put("street2", "Maple"); + Map newGeo = new HashMap<>(); + newGeo.put("latitude", "X"); + newGeo.put("longitude", "Y"); + newAddress.put("geo", newGeo); + + RequestScope goodScope = buildRequestScope(tx, goodUser); + PersistentResource parentResource = new PersistentResource<>(company, "1", goodScope); + + parentResource.updateAttribute("address", newAddress); + + //check that original value was unmodified. + assertEquals("street1", originalAddress.getStreet1()); + assertEquals("street2", originalAddress.getStreet2()); + assertEquals("1", originalAddress.getGeo().getLatitude()); + assertEquals("2", originalAddress.getGeo().getLongitude()); + + //check the new value matches the expected. + assertEquals("Elm", company.getAddress().getStreet1()); + assertEquals("Maple", company.getAddress().getStreet2()); + assertEquals("X", company.getAddress().getGeo().getLatitude()); + assertEquals("Y", company.getAddress().getGeo().getLongitude()); + + goodScope.saveOrCreateObjects(); + verify(tx, times(1)).save(company, goodScope); + } + @Test public void testUpdateNullComplexAttributeSuccess() { Company company = newCompany("abc"); @@ -1846,7 +2123,8 @@ public void testLoadRecords() { .build(); when(tx.loadObjects(eq(collection), any(RequestScope.class))) - .thenReturn(Lists.newArrayList(child1, child2, child3, child4, child5)); + .thenReturn(new DataStoreIterableBuilder( + Lists.newArrayList(child1, child2, child3, child4, child5)).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); goodScope.setEntityProjection(collection); @@ -2001,7 +2279,7 @@ public void testDeletePermissionCheckedOnInverseRelationship() { right.setAllowDeleteAtFieldLevel(Sets.newHashSet(left)); //Bad User triggers the delete permission failure - when(tx.getRelation(any(), eq(left), any(), any())).thenReturn(rights); + when(tx.getToManyRelation(any(), eq(left), any(), any())).thenReturn(new DataStoreIterableBuilder(rights).build()); RequestScope badScope = buildRequestScope(tx, badUser); PersistentResource leftResource = new PersistentResource<>(left, badScope.getUUIDFor(left), badScope); @@ -2024,7 +2302,8 @@ public void testUpdatePermissionCheckedOnInverseRelationship() { List empty = new ArrayList<>(); Relationship ids = new Relationship(null, new Data<>(empty)); - when(tx.getRelation(any(), eq(left), any(), any())).thenReturn(rights); + when(tx.getToManyRelation(any(), eq(left), any(), any())) + .thenReturn(new DataStoreIterableBuilder(rights).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource leftResource = new PersistentResource<>(left, goodScope.getUUIDFor(left), goodScope); @@ -2094,7 +2373,7 @@ public void testOwningRelationshipInverseUpdates() { Parent parent = newParent(1); Child child = newChild(2); - when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(parent.getChildren()); + when(tx.getToManyRelation(any(), eq(parent), any(), any())).thenReturn(new DataStoreIterableBuilder(parent.getChildren()).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); @@ -2121,7 +2400,8 @@ public void testOwningRelationshipInverseUpdates() { assertTrue(child.getParents().contains(parent), "The non-owning relationship should also be updated"); reset(tx); - when(tx.getRelation(any(), eq(parent), any(), any())).thenReturn(parent.getChildren()); + when(tx.getToManyRelation(any(), eq(parent), any(), any())) + .thenReturn(new DataStoreIterableBuilder(parent.getChildren()).build()); parentResource.clearRelation("children"); @@ -2272,7 +2552,8 @@ public void testTransferPermissionSuccessOnUpdateManyRelationship() { Relationship ids = new Relationship(null, new Data<>(idList)); when(tx.loadObject(any(), eq(1L), any())).thenReturn(noShare1); - when(tx.getRelation(any(), eq(userModel), any(), any())).thenReturn(noshares); + when(tx.getToManyRelation(any(), eq(userModel), any(), any())) + .thenReturn(new DataStoreIterableBuilder(noshares).build()); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource userResource = @@ -2299,7 +2580,7 @@ public void testTransferPermissionSuccessOnUpdateSingularRelationship() { idList.add(new ResourceIdentifier("noshare", "1").castToResource()); Relationship ids = new Relationship(null, new Data<>(idList)); - when(tx.getRelation(any(), eq(userModel), any(), any())).thenReturn(noShare); + when(tx.getToOneRelation(any(), eq(userModel), any(), any())).thenReturn(noShare); when(tx.loadObject(any(), eq(1L), any())).thenReturn(noShare); RequestScope goodScope = buildRequestScope(tx, goodUser); @@ -2325,7 +2606,7 @@ public void testTransferPermissionSuccessOnClearSingularRelationship() { List empty = new ArrayList<>(); Relationship ids = new Relationship(null, new Data<>(empty)); - when(tx.getRelation(any(), eq(userModel), any(), any())).thenReturn(noShare); + when(tx.getToOneRelation(any(), eq(userModel), any(), any())).thenReturn(noShare); RequestScope goodScope = buildRequestScope(tx, goodUser); PersistentResource userResource = @@ -2414,7 +2695,8 @@ public void testCollectionChangeSpecType() { PersistentResource model = bootstrapPersistentResource(csModel, tx); - when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(new HashSet<>()); + when(tx.getToManyRelation(any(), eq(model.obj), any(), any())) + .thenReturn(new DataStoreIterableBuilder<>().build()); /* Attributes */ // Set new data from null @@ -2493,7 +2775,8 @@ public void testCollectionChangeSpecType() { && modified.contains(new ChangeSpecChild(3))); model.removeRelation("otherKids", bootstrapPersistentResource(child2)); - when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(Sets.newHashSet(child1, child3)); + when(tx.getToManyRelation(any(), eq(model.obj), any(), any())).thenReturn( + new DataStoreIterableBuilder(Sets.newHashSet(child1, child3)).build()); // Clear the rest model.getObject().checkFunction = (spec) -> collectionCheck.apply("otherKids").test(spec, (original, modified) @@ -2534,11 +2817,11 @@ public void testRelationChangeSpecType() { -> relCheck.test(spec, (original, modified) -> (original == null) && new ChangeSpecChild(1).equals(modified))), tx); - when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(null); + when(tx.getToOneRelation(any(), eq(model.obj), any(), any())).thenReturn(null); ChangeSpecChild child1 = new ChangeSpecChild(1); assertTrue(model.updateRelation("child", Sets.newHashSet(bootstrapPersistentResource(child1, tx)))); - when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(child1); + when(tx.getToOneRelation(any(), eq(model.obj), any(), any())).thenReturn(child1); model.getObject().checkFunction = (spec) -> relCheck.test( spec, @@ -2548,7 +2831,7 @@ public void testRelationChangeSpecType() { ChangeSpecChild child2 = new ChangeSpecChild(2); assertTrue(model.updateRelation("child", Sets.newHashSet(bootstrapPersistentResource(child2, tx)))); - when(tx.getRelation(any(), eq(model.obj), any(), any())).thenReturn(child2); + when(tx.getToOneRelation(any(), eq(model.obj), any(), any())).thenReturn(child2); model.getObject().checkFunction = (spec) -> relCheck .test(spec, (original, modified) -> new ChangeSpecChild(2).equals(original) && modified == null); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/audit/LogMessageImplTest.java b/elide-core/src/test/java/com/yahoo/elide/core/audit/LogMessageImplTest.java index 27b8c24c8a..32db1ee8e9 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/audit/LogMessageImplTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/audit/LogMessageImplTest.java @@ -22,7 +22,6 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -133,7 +132,7 @@ public void threadSafetyTest() { assertTrue(exceptions.isEmpty(), exceptions.stream().map(Throwable::getMessage).findFirst().orElse("")); } - public void threadSafeLogger() throws IOException, InterruptedException { + public void threadSafeLogger() throws InterruptedException { TestLoggerException testException = new TestLoggerException(); LogMessageImpl failMessage = new LogMessageImpl("test", 0) { @Override diff --git a/elide-core/src/test/java/com/yahoo/elide/core/datastore/DataStoreTransactionTest.java b/elide-core/src/test/java/com/yahoo/elide/core/datastore/DataStoreTransactionTest.java index 7840fabb95..004ee4881f 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/datastore/DataStoreTransactionTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/DataStoreTransactionTest.java @@ -8,7 +8,6 @@ import static com.yahoo.elide.core.type.ClassType.STRING_TYPE; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -20,16 +19,18 @@ import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Relationship; import com.yahoo.elide.core.type.ClassType; +import com.google.common.collect.Lists; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import java.io.IOException; import java.io.Serializable; import java.util.Arrays; -import java.util.Optional; +import java.util.List; public class DataStoreTransactionTest implements DataStoreTransaction { private static final String NAME = "name"; + private static final String NAME2 = "name2"; private static final Attribute NAME_ATTRIBUTE = Attribute.builder().name(NAME).type(String.class).build(); private static final String ENTITY = "entity"; private RequestScope scope; @@ -43,6 +44,8 @@ public void setupMocks() { when(scope.getDictionary()).thenReturn(dictionary); when(dictionary.getIdType(STRING_TYPE)).thenReturn(new ClassType(Long.class)); when(dictionary.getValue(ENTITY, NAME, scope)).thenReturn(3L); + when(dictionary.getValue(ENTITY, NAME2, scope)) + .thenReturn(new DataStoreIterableBuilder(List.of(1L, 2L, 3L)).build()); } @Test @@ -51,24 +54,6 @@ public void testPreCommit() { verify(scope, never()).getDictionary(); } - @Test - public void testSupportsSorting() { - boolean actual = supportsSorting(null, Optional.empty(), null); - assertTrue(actual); - } - - @Test - public void testSupportsPagination() { - boolean actual = supportsPagination(null, Optional.empty(), null); - assertTrue(actual); - } - - @Test - public void testSupportsFiltering() { - DataStoreTransaction.FeatureSupport actual = supportsFiltering(null, Optional.empty(), null); - assertEquals(DataStoreTransaction.FeatureSupport.FULL, actual); - } - @Test public void testGetAttribute() { Object actual = getAttribute(ENTITY, NAME_ATTRIBUTE, scope); @@ -95,8 +80,8 @@ public void testUpdateToManyRelation() { } @Test - public void testGetRelation() { - Object actual = getRelation(this, ENTITY, Relationship.builder() + public void testGetToOneRelation() { + Long actual = getToOneRelation(this, ENTITY, Relationship.builder() .name(NAME) .projection(EntityProjection.builder() .type(String.class) @@ -105,6 +90,19 @@ public void testGetRelation() { assertEquals(3L, actual); } + @Test + public void testGetToManyRelation() { + DataStoreIterable actual = getToOneRelation(this, ENTITY, Relationship.builder() + .name(NAME2) + .projection(EntityProjection.builder() + .type(String.class) + .build()) + .build(), scope); + assertEquals( + Lists.newArrayList(new DataStoreIterableBuilder(List.of(1L, 2L, 3L)).build()), + Lists.newArrayList(actual)); + } + @Test public void testLoadObject() { String string = (String) loadObject(EntityProjection.builder().type(String.class).build(), 2L, scope); @@ -118,8 +116,8 @@ public Object loadObject(EntityProjection entityProjection, Serializable id, Req } @Override - public Iterable loadObjects(EntityProjection entityProjection, RequestScope scope) { - return Arrays.asList(ENTITY); + public DataStoreIterable loadObjects(EntityProjection entityProjection, RequestScope scope) { + return new DataStoreIterableBuilder(Arrays.asList(ENTITY)).build(); } @Override diff --git a/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/FilteredIteratorTest.java b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/FilteredIteratorTest.java new file mode 100644 index 0000000000..7c2891185f --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/FilteredIteratorTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.datastore.inmemory; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.TestRequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.type.ClassType; +import example.Book; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +public class FilteredIteratorTest { + + @Test + public void testFilteredResult() throws Exception { + + EntityDictionary dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); + + Book book1 = new Book(); + book1.setTitle("foo"); + Book book2 = new Book(); + book2.setTitle("bar"); + Book book3 = new Book(); + book3.setTitle("foobar"); + List books = List.of(book1, book2, book3); + + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); + + FilterExpression expression = + filterDialect.parse(ClassType.of(Book.class), new HashSet<>(), "title==*bar", NO_VERSION); + + RequestScope scope = new TestRequestScope(null, null, dictionary); + + Iterator bookIterator = new FilteredIterator<>(expression, scope, books.iterator()); + + assertTrue(bookIterator.hasNext()); + assertEquals("bar", bookIterator.next().getTitle()); + assertTrue(bookIterator.hasNext()); + assertEquals("foobar", bookIterator.next().getTitle()); + assertFalse(bookIterator.hasNext()); + } + + @Test + public void testEmptyResult() throws Exception { + + EntityDictionary dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); + + List books = List.of(); + + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); + + FilterExpression expression = + filterDialect.parse(ClassType.of(Book.class), new HashSet<>(), "title==*bar", NO_VERSION); + + RequestScope scope = new TestRequestScope(null, null, dictionary); + + Iterator bookIterator = new FilteredIterator<>(expression, scope, books.iterator()); + + + assertFalse(bookIterator.hasNext()); + assertThrows(NoSuchElementException.class, () -> bookIterator.next()); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java index fc27ec6527..0dc3d7bec8 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/inmemory/InMemoryStoreTransactionTest.java @@ -20,9 +20,10 @@ import com.yahoo.elide.core.Path; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; -import com.yahoo.elide.core.filter.expression.AndFilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.filter.predicates.InPredicate; import com.yahoo.elide.core.pagination.PaginationImpl; @@ -32,6 +33,7 @@ import com.yahoo.elide.core.sort.SortingImpl; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Lists; import com.google.common.collect.Sets; @@ -39,6 +41,7 @@ import example.Author; import example.Book; import example.Editor; +import example.Price; import example.Publisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -48,10 +51,9 @@ import java.util.Arrays; import java.util.Collection; import java.util.HashMap; -import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.stream.Collectors; public class InMemoryStoreTransactionTest { @@ -60,10 +62,12 @@ public class InMemoryStoreTransactionTest { private RequestScope scope = mock(RequestScope.class); private InMemoryStoreTransaction inMemoryStoreTransaction = new InMemoryStoreTransaction(wrappedTransaction); private EntityDictionary dictionary; - private Set books = new HashSet<>(); + private LinkedHashSet books = new LinkedHashSet(); private Book book1; private Book book2; private Book book3; + private Publisher publisher1; + private Publisher publisher2; private Author author1; private Author author2; private ElideSettings elideSettings; @@ -97,10 +101,10 @@ public InMemoryStoreTransactionTest() { editor2.setFirstName("Jane"); editor2.setLastName("Doe"); - Publisher publisher1 = new Publisher(); + publisher1 = new Publisher(); publisher1.setEditor(editor1); - Publisher publisher2 = new Publisher(); + publisher2 = new Publisher(); publisher2.setEditor(editor2); book1 = new Book(1, @@ -110,7 +114,8 @@ public InMemoryStoreTransactionTest() { System.currentTimeMillis(), Sets.newHashSet(author1), publisher1, - Arrays.asList("Prize1")); + Arrays.asList("Prize1"), + new Price()); book2 = new Book(2, "Book 2", @@ -119,7 +124,8 @@ public InMemoryStoreTransactionTest() { System.currentTimeMillis(), Sets.newHashSet(author1), publisher1, - Arrays.asList("Prize1", "Prize2")); + Arrays.asList("Prize1", "Prize2"), + new Price()); book3 = new Book(3, "Book 3", @@ -128,7 +134,8 @@ public InMemoryStoreTransactionTest() { System.currentTimeMillis(), Sets.newHashSet(author1), publisher2, - Arrays.asList()); + Arrays.asList(), + new Price()); books.add(book1); books.add(book2); @@ -154,21 +161,19 @@ public void testFullFilterPredicatePushDown() { .filterExpression(expression) .build(); - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.loadObjects(eq(projection), eq(scope))).thenReturn(books); + DataStoreIterable expected = new DataStoreIterableBuilder<>(books).build(); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects(projection, scope); + when(wrappedTransaction.loadObjects(eq(projection), eq(scope))) + .thenReturn(expected); - verify(wrappedTransaction, times(1)).loadObjects(eq(projection), eq(scope)); + DataStoreIterable actual = inMemoryStoreTransaction.loadObjects(projection, scope); + assertEquals(expected, actual); - assertEquals(3, loaded.size()); - assertTrue(loaded.contains(book1)); - assertTrue(loaded.contains(book2)); - assertTrue(loaded.contains(book3)); + verify(wrappedTransaction, times(1)).loadObjects(eq(projection), eq(scope)); } @Test - public void testFilterPredicateOnComplexAttribute() { + public void testFilterPredicateInMemoryOnComplexAttribute() { FilterExpression expression = new InPredicate(new Path(Author.class, dictionary, "homeAddress.street1"), "Foo"); @@ -177,15 +182,52 @@ public void testFilterPredicateOnComplexAttribute() { .filterExpression(expression) .build(); - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.NONE); - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(Arrays.asList(author1, author2)); + DataStoreIterable filterInMemory = + new DataStoreIterableBuilder(Arrays.asList(author1, author2)).filterInMemory(true).build(); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects(projection, scope); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(filterInMemory); + + Collection loaded = ImmutableList.copyOf(inMemoryStoreTransaction.loadObjects(projection, scope)); assertEquals(1, loaded.size()); assertTrue(loaded.contains(author1)); } + @Test + public void testSortOnComputedAttribute() { + Map sortOrder = new HashMap<>(); + sortOrder.put("fullName", Sorting.SortOrder.desc); + + Editor editor1 = new Editor(); + editor1.setFirstName("A"); + editor1.setLastName("X"); + Editor editor2 = new Editor(); + editor2.setFirstName("B"); + editor2.setLastName("Y"); + + Sorting sorting = new SortingImpl(sortOrder, Editor.class, dictionary); + + EntityProjection projection = EntityProjection.builder() + .type(Editor.class) + .sorting(sorting) + .build(); + + DataStoreIterable iterable = + new DataStoreIterableBuilder(Arrays.asList(editor1, editor2)).sortInMemory(false).build(); + + ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); + when(wrappedTransaction.loadObjects(projectionArgument.capture(), eq(scope))).thenReturn(iterable); + + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects(projection, scope)); + + assertNull(projectionArgument.getValue().getSorting()); + assertEquals(2, loaded.size()); + + Object[] sorted = loaded.toArray(); + assertEquals(editor2, sorted[0]); + assertEquals(editor1, sorted[1]); + } + @Test public void testSortOnComplexAttribute() { Map sortOrder = new HashMap<>(); @@ -198,10 +240,12 @@ public void testSortOnComplexAttribute() { .sorting(sorting) .build(); - when(wrappedTransaction.supportsSorting(eq(scope), any(), eq(projection))).thenReturn(false); - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(Arrays.asList(author1, author2)); + DataStoreIterable sortInMemory = + new DataStoreIterableBuilder(Arrays.asList(author1, author2)).sortInMemory(true).build(); + + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(sortInMemory); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects(projection, scope); + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects(projection, scope)); assertEquals(2, loaded.size()); @@ -227,13 +271,14 @@ public void testTransactionRequiresInMemoryFilterDuringGetRelation() { ArgumentCaptor relationshipArgument = ArgumentCaptor.forClass(Relationship.class); when(scope.getNewPersistentResources()).thenReturn(Sets.newHashSet(mock(PersistentResource.class))); - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(relationship.getProjection()))).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.getRelation(eq(inMemoryStoreTransaction), eq(author1), any(), eq(scope))).thenReturn(books); - Collection loaded = (Collection) inMemoryStoreTransaction.getRelation( - inMemoryStoreTransaction, author1, relationship, scope); + when(wrappedTransaction.getToManyRelation(eq(inMemoryStoreTransaction), eq(author1), any(), eq(scope))) + .thenReturn(new DataStoreIterableBuilder<>(books).build()); + + Collection loaded = ImmutableList.copyOf((Iterable) inMemoryStoreTransaction.getToManyRelation( + inMemoryStoreTransaction, author1, relationship, scope)); - verify(wrappedTransaction, times(1)).getRelation( + verify(wrappedTransaction, times(1)).getToManyRelation( eq(inMemoryStoreTransaction), eq(author1), relationshipArgument.capture(), @@ -249,68 +294,57 @@ public void testTransactionRequiresInMemoryFilterDuringGetRelation() { } @Test - public void testDataStoreRequiresTotalInMemoryFilter() { - FilterExpression expression = - new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); + public void testGetToOneRelationship() { - EntityProjection projection = EntityProjection.builder() - .type(Book.class) - .filterExpression(expression) + Relationship relationship = Relationship.builder() + .projection(EntityProjection.builder() + .type(Book.class) + .build()) + .name("publisher") + .alias("publisher") .build(); - ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); - - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.NONE); - - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); + when(wrappedTransaction.getToOneRelation(eq(inMemoryStoreTransaction), eq(book1), any(), eq(scope))) + .thenReturn(publisher1); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects(projection, scope); + Publisher loaded = inMemoryStoreTransaction.getToOneRelation( + inMemoryStoreTransaction, book1, relationship, scope); - verify(wrappedTransaction, times(1)).loadObjects( - projectionArgument.capture(), + verify(wrappedTransaction, times(1)).getToOneRelation( + eq(inMemoryStoreTransaction), + eq(book1), + eq(relationship), eq(scope)); - assertNull(projectionArgument.getValue().getFilterExpression()); - assertNull(projectionArgument.getValue().getPagination()); - assertNull(projectionArgument.getValue().getSorting()); - assertEquals(2, loaded.size()); - assertTrue(loaded.contains(book1)); - assertTrue(loaded.contains(book3)); + assertEquals(publisher1, loaded); } @Test - public void testDataStoreRequiresPartialInMemoryFilter() { - FilterExpression expression1 = + public void testDataStoreRequiresTotalInMemoryFilter() { + FilterExpression expression = new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); - FilterExpression expression2 = - new InPredicate(new Path(Book.class, dictionary, "editor.firstName"), "Jane"); - FilterExpression expression = new AndFilterExpression(expression1, expression2); EntityProjection projection = EntityProjection.builder() .type(Book.class) .filterExpression(expression) .build(); - ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); + DataStoreIterable filterInMemory = new DataStoreIterableBuilder(books).filterInMemory(true).build(); - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.PARTIAL); - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(filterInMemory); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - projection, - scope); + Collection loaded = ImmutableList.copyOf(inMemoryStoreTransaction.loadObjects(projection, scope)); verify(wrappedTransaction, times(1)).loadObjects( - projectionArgument.capture(), + any(EntityProjection.class), eq(scope)); - assertEquals(projectionArgument.getValue().getFilterExpression(), expression1); - assertNull(projectionArgument.getValue().getPagination()); - assertNull(projectionArgument.getValue().getSorting()); - assertEquals(1, loaded.size()); + assertEquals(2, loaded.size()); + assertTrue(loaded.contains(book1)); assertTrue(loaded.contains(book3)); } + @Test public void testSortingPushDown() { Map sortOrder = new HashMap<>(); @@ -323,30 +357,23 @@ public void testSortingPushDown() { .sorting(sorting) .build(); - ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); + DataStoreIterable expected = new DataStoreIterableBuilder<>(books).build(); + when(wrappedTransaction.loadObjects(any(), eq(scope))) + .thenReturn(expected); - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.supportsSorting(eq(scope), any(), eq(projection))).thenReturn(true); - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); - - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( - projection, - scope); + DataStoreIterable actual = inMemoryStoreTransaction.loadObjects(projection, scope); verify(wrappedTransaction, times(1)).loadObjects( - projectionArgument.capture(), + eq(projection), eq(scope)); - assertNull(projectionArgument.getValue().getFilterExpression()); - assertNull(projectionArgument.getValue().getPagination()); - assertEquals(projectionArgument.getValue().getSorting(), sorting); - assertEquals(3, loaded.size()); + assertEquals(expected, actual); } @Test public void testDataStoreRequiresInMemorySorting() { Map sortOrder = new HashMap<>(); - sortOrder.put("title", Sorting.SortOrder.asc); + sortOrder.put("title", Sorting.SortOrder.desc); Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); @@ -355,27 +382,22 @@ public void testDataStoreRequiresInMemorySorting() { .sorting(sorting) .build(); - ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); + DataStoreIterable sortInMemory = new DataStoreIterableBuilder(books).sortInMemory(true).build(); - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.supportsSorting(eq(scope), any(), eq(projection))).thenReturn(false); - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(sortInMemory); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( projection, - scope); + scope)); verify(wrappedTransaction, times(1)).loadObjects( - projectionArgument.capture(), + any(EntityProjection.class), eq(scope)); - assertNull(projectionArgument.getValue().getFilterExpression()); - assertNull(projectionArgument.getValue().getPagination()); - assertNull(projectionArgument.getValue().getSorting()); assertEquals(3, loaded.size()); List bookTitles = loaded.stream().map((o) -> ((Book) o).getTitle()).collect(Collectors.toList()); - assertEquals(bookTitles, Lists.newArrayList("Book 1", "Book 2", "Book 3")); + assertEquals(bookTitles, Lists.newArrayList("Book 3", "Book 2", "Book 1")); } @Test @@ -384,7 +406,7 @@ public void testFilteringRequiresInMemorySorting() { new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); Map sortOrder = new HashMap<>(); - sortOrder.put("title", Sorting.SortOrder.asc); + sortOrder.put("title", Sorting.SortOrder.desc); Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); @@ -394,32 +416,27 @@ public void testFilteringRequiresInMemorySorting() { .sorting(sorting) .build(); - ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); + DataStoreIterable filterInMemory = new DataStoreIterableBuilder(books).filterInMemory(true).build(); - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.NONE); - when(wrappedTransaction.supportsSorting(eq(scope), any(), eq(projection))).thenReturn(true); - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(filterInMemory); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( projection, - scope); + scope)); verify(wrappedTransaction, times(1)).loadObjects( - projectionArgument.capture(), + any(EntityProjection.class), eq(scope)); - assertNull(projectionArgument.getValue().getFilterExpression()); - assertNull(projectionArgument.getValue().getPagination()); - assertNull(projectionArgument.getValue().getSorting()); assertEquals(2, loaded.size()); List bookTitles = loaded.stream().map((o) -> ((Book) o).getTitle()).collect(Collectors.toList()); - assertEquals(Lists.newArrayList("Book 1", "Book 3"), bookTitles); + assertEquals(Lists.newArrayList("Book 3", "Book 1"), bookTitles); } @Test public void testPaginationPushDown() { - PaginationImpl pagination = PaginationImpl.getDefaultPagination(ClassType.of(Book.class), elideSettings); + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 1, 10, 10, false, false); EntityProjection projection = EntityProjection.builder() .type(Book.class) @@ -428,56 +445,45 @@ public void testPaginationPushDown() { ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.supportsPagination(eq(scope), any(), eq(projection))).thenReturn(true); - - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); + when(wrappedTransaction.loadObjects(any(), eq(scope))) + .thenReturn(new DataStoreIterableBuilder<>(books).build()); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( projection, - scope); + scope)); verify(wrappedTransaction, times(1)).loadObjects( projectionArgument.capture(), eq(scope)); - assertNull(projectionArgument.getValue().getFilterExpression()); - assertEquals(projectionArgument.getValue().getPagination(), pagination); - assertNull(projectionArgument.getValue().getSorting()); + assertEquals(pagination, projectionArgument.getValue().getPagination()); assertEquals(3, loaded.size()); } @Test public void testDataStoreRequiresInMemoryPagination() { - PaginationImpl pagination = PaginationImpl.getDefaultPagination(ClassType.of(Book.class), elideSettings); + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 2, 10, 10, false, false); EntityProjection projection = EntityProjection.builder() .type(Book.class) .pagination(pagination) .build(); - ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); - - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.supportsPagination(eq(scope), any(), eq(projection))).thenReturn(false); + DataStoreIterable paginateInMemory = new DataStoreIterableBuilder(books).paginateInMemory(true).build(); - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(paginateInMemory); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( projection, - scope); + scope)); verify(wrappedTransaction, times(1)).loadObjects( - projectionArgument.capture(), + any(EntityProjection.class), eq(scope)); - assertNull(projectionArgument.getValue().getFilterExpression()); - assertNull(projectionArgument.getValue().getPagination()); - assertNull(projectionArgument.getValue().getSorting()); - assertEquals(3, loaded.size()); + assertEquals(2, loaded.size()); assertTrue(loaded.contains(book1)); assertTrue(loaded.contains(book2)); - assertTrue(loaded.contains(book3)); } @Test @@ -485,7 +491,7 @@ public void testFilteringRequiresInMemoryPagination() { FilterExpression expression = new InPredicate(new Path(Book.class, dictionary, "genre"), "Literary Fiction"); - PaginationImpl pagination = PaginationImpl.getDefaultPagination(ClassType.of(Book.class), elideSettings); + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 2, 10, 10, true, false); EntityProjection projection = EntityProjection.builder() .type(Book.class) @@ -493,35 +499,30 @@ public void testFilteringRequiresInMemoryPagination() { .pagination(pagination) .build(); - ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); - - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.NONE); - when(wrappedTransaction.supportsPagination(eq(scope), any(), eq(projection))).thenReturn(true); + DataStoreIterable filterInMemory = new DataStoreIterableBuilder(books).filterInMemory(true).build(); - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(filterInMemory); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( projection, - scope); + scope)); verify(wrappedTransaction, times(1)).loadObjects( - projectionArgument.capture(), + any(EntityProjection.class), eq(scope)); - assertNull(projectionArgument.getValue().getFilterExpression()); - assertNull(projectionArgument.getValue().getPagination()); - assertNull(projectionArgument.getValue().getSorting()); assertEquals(2, loaded.size()); assertTrue(loaded.contains(book1)); assertTrue(loaded.contains(book3)); + assertEquals(2, pagination.getPageTotals()); } @Test public void testSortingRequiresInMemoryPagination() { - PaginationImpl pagination = PaginationImpl.getDefaultPagination(ClassType.of(Book.class), elideSettings); + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 3, 10, 10, true, false); Map sortOrder = new HashMap<>(); - sortOrder.put("title", Sorting.SortOrder.asc); + sortOrder.put("title", Sorting.SortOrder.desc); Sorting sorting = new SortingImpl(sortOrder, Book.class, dictionary); @@ -531,29 +532,22 @@ public void testSortingRequiresInMemoryPagination() { .pagination(pagination) .build(); - ArgumentCaptor projectionArgument = ArgumentCaptor.forClass(EntityProjection.class); - - when(wrappedTransaction.supportsFiltering(eq(scope), any(), eq(projection))).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - when(wrappedTransaction.supportsSorting(eq(scope), any(), eq(projection))).thenReturn(false); - when(wrappedTransaction.supportsPagination(eq(scope), any(), eq(projection))).thenReturn(true); + DataStoreIterable sortInMemory = new DataStoreIterableBuilder(books).sortInMemory(true).build(); - when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(books); + when(wrappedTransaction.loadObjects(any(), eq(scope))).thenReturn(sortInMemory); - Collection loaded = (Collection) inMemoryStoreTransaction.loadObjects( + Collection loaded = Lists.newArrayList(inMemoryStoreTransaction.loadObjects( projection, - scope); + scope)); verify(wrappedTransaction, times(1)).loadObjects( - projectionArgument.capture(), + any(EntityProjection.class), eq(scope)); - assertNull(projectionArgument.getValue().getFilterExpression()); - assertNull(projectionArgument.getValue().getPagination()); - assertNull(projectionArgument.getValue().getSorting()); assertEquals(3, loaded.size()); - assertTrue(loaded.contains(book1)); - assertTrue(loaded.contains(book2)); - assertTrue(loaded.contains(book3)); + List bookTitles = loaded.stream().map((o) -> ((Book) o).getTitle()).collect(Collectors.toList()); + assertEquals(Lists.newArrayList("Book 3", "Book 2", "Book 1"), bookTitles); + assertEquals(3, pagination.getPageTotals()); } @Test diff --git a/elide-core/src/test/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapperTest.java b/elide-core/src/test/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapperTest.java index bfb171a867..9983adad9c 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapperTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/datastore/wrapped/TransactionWrapperTest.java @@ -7,19 +7,17 @@ package com.yahoo.elide.core.datastore.wrapped; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.request.Attribute; import org.junit.jupiter.api.Test; -import java.util.Optional; - public class TransactionWrapperTest { private static class TestTransactionWrapper extends TransactionWrapper { @@ -52,7 +50,7 @@ public void testLoadObjects() throws Exception { DataStoreTransaction wrapped = mock(DataStoreTransaction.class); DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - Iterable expected = mock(Iterable.class); + DataStoreIterable expected = mock(DataStoreIterable.class); when(wrapped.loadObjects(any(), any())).thenReturn(expected); Iterable actual = wrapper.loadObjects(null, null); @@ -111,42 +109,6 @@ public void testSave() { verify(wrapped, times(1)).save(any(), any()); } - @Test - public void testSupportsSorting() { - DataStoreTransaction wrapped = mock(DataStoreTransaction.class); - DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - - when(wrapped.supportsSorting(any(), any(), any())).thenReturn(true); - boolean actual = wrapper.supportsSorting(null, Optional.empty(), null); - - verify(wrapped, times(1)).supportsSorting(any(), any(), any()); - assertTrue(actual); - } - - @Test - public void testSupportsPagination() { - DataStoreTransaction wrapped = mock(DataStoreTransaction.class); - DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - - when(wrapped.supportsPagination(any(), any(), any())).thenReturn(true); - boolean actual = wrapper.supportsPagination(null, Optional.empty(), null); - - verify(wrapped, times(1)).supportsPagination(any(), any(), any()); - assertTrue(actual); - } - - @Test - public void testSupportsFiltering() { - DataStoreTransaction wrapped = mock(DataStoreTransaction.class); - DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - - when(wrapped.supportsFiltering(any(), any(), any())).thenReturn(DataStoreTransaction.FeatureSupport.FULL); - DataStoreTransaction.FeatureSupport actual = wrapper.supportsFiltering(null, Optional.empty(), null); - - verify(wrapped, times(1)).supportsFiltering(any(), any(), any()); - assertEquals(DataStoreTransaction.FeatureSupport.FULL, actual); - } - @Test public void testGetAttribute() { DataStoreTransaction wrapped = mock(DataStoreTransaction.class); @@ -191,15 +153,29 @@ public void testUpdateToManyRelation() { } @Test - public void testGetRelation() { + public void testGetToManyRelation() { + DataStoreTransaction wrapped = mock(DataStoreTransaction.class); + DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); + + DataStoreIterable expected = mock(DataStoreIterable.class); + when(wrapped.getToManyRelation(any(), any(), any(), any())).thenReturn(expected); + + DataStoreIterable actual = wrapper.getToManyRelation(null, null, null, null); + + verify(wrapped, times(1)).getToManyRelation(any(), any(), any(), any()); + assertEquals(expected, actual); + } + + @Test + public void testGetToOneRelation() { DataStoreTransaction wrapped = mock(DataStoreTransaction.class); DataStoreTransaction wrapper = new TestTransactionWrapper(wrapped); - when(wrapped.getRelation(any(), any(), any(), any())).thenReturn(1L); + when(wrapped.getToOneRelation(any(), any(), any(), any())).thenReturn(1L); - Object actual = wrapper.getRelation(null, null, null, null); + Long actual = wrapper.getToOneRelation(null, null, null, null); - verify(wrapped, times(1)).getRelation(any(), any(), any(), any()); + verify(wrapped, times(1)).getToOneRelation(any(), any(), any(), any()); assertEquals(1L, actual); } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityDictionaryTest.java b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityDictionaryTest.java index 1fc521234e..a21a606f25 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityDictionaryTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/dictionary/EntityDictionaryTest.java @@ -30,6 +30,7 @@ import com.yahoo.elide.core.security.checks.prefab.Collections.AppendOnly; import com.yahoo.elide.core.security.checks.prefab.Collections.RemoveOnly; import com.yahoo.elide.core.security.checks.prefab.Role; +import com.yahoo.elide.core.type.AccessibleObject; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.DefaultClassScanner; @@ -50,6 +51,7 @@ import example.Job; import example.Left; import example.Parent; +import example.Price; import example.Publisher; import example.Right; import example.StringId; @@ -73,7 +75,6 @@ import java.util.Collections; import java.util.Date; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -173,30 +174,6 @@ public void testBindingNoExcludeSet() { assertNotNull(testDictionary.entityBindings.get(ClassType.of(Employee.class))); } - @Test - public void testBindingExcludeSet() { - Set> entitiesToExclude = new HashSet<>(); - entitiesToExclude.add(ClassType.of(Employee.class)); - - EntityDictionary testDictionary = EntityDictionary.builder().entitiesToExclude(entitiesToExclude).build(); - testDictionary.bindEntity(Employee.class); - // Does not find the Binding - assertNull(testDictionary.entityBindings.get(ClassType.of(Employee.class))); - } - - @Test - public void testEntityBindingExcludeSet() { - - Set> entitiesToExclude = new HashSet<>(); - entitiesToExclude.add(ClassType.of(Employee.class)); - - EntityDictionary testDictionary = EntityDictionary.builder().entitiesToExclude(entitiesToExclude).build(); - testDictionary.bindEntity(new EntityBinding(testDictionary.getInjector(), - ClassType.of(Employee.class), "employee")); - // Does not find the Binding - assertNull(testDictionary.entityBindings.get(ClassType.of(Employee.class))); - } - @Test public void testCheckScan() { @@ -292,7 +269,7 @@ class Foo2 { LifeCycleHook trigger = mock(LifeCycleHook.class); bindTrigger(Foo2.class, "bar", UPDATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY, trigger); - assertEquals(1, getAllFields(ClassType.of(Foo2.class)).size()); + assertEquals(1, getAllExposedFields(ClassType.of(Foo2.class)).size()); } @Test @@ -309,7 +286,7 @@ class Foo3 { LifeCycleHook trigger = mock(LifeCycleHook.class); bindTrigger(Foo3.class, UPDATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY, trigger, true); - assertEquals(1, getAllFields(ClassType.of(Foo3.class)).size()); + assertEquals(1, getAllExposedFields(ClassType.of(Foo3.class)).size()); } @Test @@ -326,7 +303,7 @@ class Foo4 { LifeCycleHook trigger = mock(LifeCycleHook.class); bindTrigger(Foo4.class, UPDATE, LifeCycleHookBinding.TransactionPhase.PRESECURITY, trigger, false); - assertEquals(1, getAllFields(ClassType.of(Foo4.class)).size()); + assertEquals(1, getAllExposedFields(ClassType.of(Foo4.class)).size()); } @Test @@ -360,7 +337,7 @@ public void setComputedProperty() { assertEquals(AccessType.FIELD, getAccessType(ClassType.of(FieldLevelTest.class))); - List fields = getAllFields(ClassType.of(FieldLevelTest.class)); + List fields = getAllExposedFields(ClassType.of(FieldLevelTest.class)); assertEquals(3, fields.size()); assertTrue(fields.contains("bar")); assertTrue(fields.contains("computedField")); @@ -405,7 +382,7 @@ public void setComputedProperty() { assertEquals(AccessType.PROPERTY, getAccessType(ClassType.of(PropertyLevelTest.class))); - List fields = getAllFields(ClassType.of(PropertyLevelTest.class)); + List fields = getAllExposedFields(ClassType.of(PropertyLevelTest.class)); assertEquals(2, fields.size()); assertTrue(fields.contains("bar")); assertTrue(fields.contains("computedProperty")); @@ -438,6 +415,14 @@ public void testGetParameterizedType() { ClassType.of(Employee.class), getParameterizedType(ClassType.of(Manager.class), "minions"), "getParameterizedType returns the correct generic type of a to-many relationship"); + + assertEquals( + ClassType.of(Book.class), + getParameterizedType(ClassType.of(Author.class), "products"), + "getParameterizedType returns the correct targetEntity type of a to-many relationship"); + + assertEquals(ClassType.of(Manager.class), getParameterizedType(ClassType.of(Employee.class), "boss"), + "getParameterizedType returns the correct generic type of a to-one relationship"); } @Test @@ -463,6 +448,29 @@ class NonGeneratedIdModel { assertFalse(isIdGenerated(ClassType.of(NonGeneratedIdModel.class))); } + @Test + public void testHiddenFields() { + @Include + class Model { + @Id + private long id; + + private String field1; + private String field2; + } + + bindEntity(Model.class, (field) -> field.getName().equals("field1")); + + Type modelType = ClassType.of(Model.class); + + assertEquals(List.of("field2"), getAllExposedFields(modelType)); + + EntityBinding binding = getEntityBinding(modelType); + assertEquals(List.of("id", "field1", "field2"), binding.getAllFields().stream() + .map(AccessibleObject::getName) + .collect(Collectors.toList())); + } + @Test public void testGetInverseRelationshipOwningSide() { assertEquals( @@ -611,7 +619,7 @@ public void testGetType() throws Exception { assertEquals(ClassType.of(String.class), getType(ClassType.of(Friend.class), "name"), "getType returns the type of attribute when defined in a super class"); - assertEquals(ClassType.of(Manager.class), getType(ClassType.of(Employee.class), "boss"), + assertEquals(ClassType.of(Object.class), getType(ClassType.of(Employee.class), "boss"), "getType returns the correct generic type of a to-one relationship"); assertEquals(ClassType.of(Set.class), getType(ClassType.of(Manager.class), "minions"), @@ -630,6 +638,9 @@ public void testGetType() throws Exception { "getType returns the type of surrogate key"); assertEquals(ClassType.of(String.class), getType(ClassType.of(StringId.class), "id"), "getType returns the type of surrogate key"); + + // Test targetEntity on a method. + assertEquals(ClassType.of(Collection.class), getType(ClassType.of(Author.class), "products")); } @Test @@ -1059,6 +1070,24 @@ public void testIsValidField() { assertFalse(isValidField(ClassType.of(Job.class), "foo")); } + @Test + public void testBindingHiddenAttribute() { + @Include + class Book { + @Id + long id; + + String notHidden; + + String hidden; + } + + bindEntity(Book.class, (field) -> field.getName().equals("hidden") ? true : false); + + assertFalse(isAttribute(ClassType.of(Book.class), "hidden")); + assertTrue(isAttribute(ClassType.of(Book.class), "notHidden")); + } + @Test public void testGetBoundByVersion() { Set> models = getBoundClassesByVersion("1.0"); @@ -1090,6 +1119,12 @@ public void testIsComplexAttribute() { assertTrue(isComplexAttribute(ClassType.of(Author.class), "homeAddress")); //Test nested complex attribute assertTrue(isComplexAttribute(ClassType.of(Address.class), "geo")); + //Test another complex attribute. + assertTrue(isComplexAttribute(ClassType.of(Book.class), "price")); + //Test Java Type with no default constructor. + assertFalse(isComplexAttribute(ClassType.of(Price.class), "currency")); + //Test embedded Elide model + assertFalse(isComplexAttribute(ClassType.of(Price.class), "book")); //Test String assertFalse(isComplexAttribute(ClassType.of(Book.class), "title")); //Test primitive @@ -1102,6 +1137,10 @@ public void testIsComplexAttribute() { assertFalse(isComplexAttribute(ClassType.of(Book.class), "authors")); //Test enum assertFalse(isComplexAttribute(ClassType.of(Author.class), "authorType")); + //Test collection of complex type + assertFalse(isComplexAttribute(ClassType.of(Author.class), "vacationHomes")); + //Test map of objects + assertFalse(isComplexAttribute(ClassType.of(Author.class), "stuff")); } @Test diff --git a/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java index 59f6fce2c6..00f3f18f39 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorMapperTest.java @@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; @@ -20,7 +21,7 @@ import com.yahoo.elide.ElideResponse; import com.yahoo.elide.ElideSettings; import com.yahoo.elide.ElideSettingsBuilder; -import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.TransactionRegistry; import com.yahoo.elide.core.datastore.DataStore; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; @@ -28,14 +29,12 @@ import com.yahoo.elide.core.lifecycle.FieldTestModel; import com.yahoo.elide.core.lifecycle.LegacyTestModel; import com.yahoo.elide.core.lifecycle.PropertyTestModel; -import com.yahoo.elide.core.security.TestUser; -import com.yahoo.elide.core.security.User; import com.yahoo.elide.core.type.ClassType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; -import java.util.UUID; +import java.io.IOException; /** * Tests the error mapping logic. @@ -63,12 +62,12 @@ public class ErrorMapperTest { } @AfterEach - private void afterEach() { + public void afterEach() { reset(MOCK_ERROR_MAPPER); } @Test - public void testElideCreateNoErrorMapper() throws Exception { + public void testElideRuntimeExceptionNoErrorMapper() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); FieldTestModel mockModel = mock(FieldTestModel.class); @@ -88,7 +87,30 @@ public void testElideCreateNoErrorMapper() throws Exception { } @Test - public void testElideCreateWithErrorMapperUnmapped() throws Exception { + public void testElideIOExceptionNoErrorMapper() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, null); + + //Invalid JSON + String body = "{\"data\": {\"type\":\"testModel\"\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; + + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(eq(ClassType.of(FieldTestModel.class)), any())).thenReturn(mockModel); + + ElideResponse response = elide.post(baseUrl, "/testModel", body, null, NO_VERSION); + assertEquals(400, response.getResponseCode()); + assertEquals( + "{\"errors\":[{\"detail\":\"Unexpected character ('"' (code 34)): was expecting comma to separate Object entries\\n at [Source: (String)"{"data": {"type":"testModel""id":"1","attributes": {"field":"Foo"}}}"; line: 1, column: 30]\"}]}", + response.getBody()); + + verify(tx).close(); + } + + @Test + public void testElideRuntimeExceptionWithErrorMapperUnmapped() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); FieldTestModel mockModel = mock(FieldTestModel.class); @@ -108,7 +130,30 @@ public void testElideCreateWithErrorMapperUnmapped() throws Exception { } @Test - public void testElideCreateWithErrorMapperMapped() throws Exception { + public void testElideIOExceptionWithErrorMapperUnmapped() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_ERROR_MAPPER); + + //Invalid JSON + String body = "{\"data\": {\"type\":\"testModel\"\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; + + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(eq(ClassType.of(FieldTestModel.class)), any())).thenReturn(mockModel); + + ElideResponse response = elide.post(baseUrl, "/testModel", body, null, NO_VERSION); + assertEquals(400, response.getResponseCode()); + assertEquals( + "{\"errors\":[{\"detail\":\"Unexpected character ('"' (code 34)): was expecting comma to separate Object entries\\n at [Source: (String)"{"data": {"type":"testModel""id":"1","attributes": {"field":"Foo"}}}"; line: 1, column: 30]\"}]}", + response.getBody()); + + verify(tx).close(); + } + + @Test + public void testElideRuntimeExceptionWithErrorMapperMapped() throws Exception { DataStore store = mock(DataStore.class); DataStoreTransaction tx = mock(DataStoreTransaction.class); FieldTestModel mockModel = mock(FieldTestModel.class); @@ -131,8 +176,34 @@ public void testElideCreateWithErrorMapperMapped() throws Exception { verify(tx).close(); } + @Test + public void testElideIOExceptionWithErrorMapperMapped() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel mockModel = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_ERROR_MAPPER); + + //Invalid JSON: + String body = "{\"data\": {\"type\":\"testModel\"\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; + + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(eq(ClassType.of(FieldTestModel.class)), any())).thenReturn(mockModel); + + when(MOCK_ERROR_MAPPER.map(isA(IOException.class))).thenReturn(MAPPED_EXCEPTION); + + ElideResponse response = elide.post(baseUrl, "/testModel", body, null, NO_VERSION); + assertEquals(422, response.getResponseCode()); + assertEquals( + "{\"errors\":[{\"code\":\"SOME_ERROR\"}]}", + response.getBody()); + + verify(tx).close(); + } + private Elide getElide(DataStore dataStore, EntityDictionary dictionary, ErrorMapper errorMapper) { - return new Elide(getElideSettings(dataStore, dictionary, errorMapper)); + ElideSettings settings = getElideSettings(dataStore, dictionary, errorMapper); + return new Elide(settings, new TransactionRegistry(), settings.getDictionary().getScanner(), false); } private ElideSettings getElideSettings(DataStore dataStore, EntityDictionary dictionary, ErrorMapper errorMapper) { @@ -142,11 +213,4 @@ private ElideSettings getElideSettings(DataStore dataStore, EntityDictionary dic .withVerboseErrors() .build(); } - - private RequestScope buildRequestScope(EntityDictionary dict, DataStoreTransaction tx) { - User user = new TestUser("1"); - - return new RequestScope(null, null, NO_VERSION, null, tx, user, null, null, UUID.randomUUID(), - getElideSettings(null, dict, MOCK_ERROR_MAPPER)); - } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorObjectsTest.java b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorObjectsTest.java new file mode 100644 index 0000000000..eccf12976b --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/exceptions/ErrorObjectsTest.java @@ -0,0 +1,34 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.exceptions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +public class ErrorObjectsTest { + @Test + public void testAddErrorLast() { + assertThrows(UnsupportedOperationException.class, () -> { + ErrorObjects errorObj = new ErrorObjects.ErrorObjectsBuilder() + .with("foo", "bar") + .addError().build(); + }); + } + + @Test + public void testAddErrorFirst() { + ErrorObjects errorObj = new ErrorObjects.ErrorObjectsBuilder() + .addError() + .with("foo", "bar") + .build(); + + assertEquals(1, errorObj.getErrors().size()); + assertEquals("bar", errorObj.getErrors().get(0).get("foo")); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/filter/FilterPredicateTest.java b/elide-core/src/test/java/com/yahoo/elide/core/filter/FilterPredicateTest.java index e5425c7221..39b6d52230 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/filter/FilterPredicateTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/filter/FilterPredicateTest.java @@ -10,6 +10,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; + import com.yahoo.elide.core.Path; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.BadRequestException; @@ -18,8 +19,10 @@ import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.filter.expression.PredicateExtractionVisitor; import com.yahoo.elide.core.filter.predicates.FilterPredicate; + import example.Author; import example.Book; + import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -29,6 +32,7 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; + import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; @@ -196,6 +200,48 @@ void testSingleFieldWithInfixOperator() { assertEquals(Arrays.asList("abc", "def"), predicate.getValues()); } + @Test + void testSingleFieldWithNotPrefixOperator() { + MultivaluedMap queryParams = new MultivaluedHashMap<>(); + queryParams.add("filter[book.title][notprefix]", "abc"); + + Map> predicates = parse(queryParams); + assertTrue(predicates.containsKey("book")); + + FilterPredicate predicate = predicates.get("book").iterator().next(); + assertEquals("title", predicate.getField()); + assertEquals(Operator.NOT_PREFIX, predicate.getOperator()); + assertEquals(Collections.singletonList("abc"), predicate.getValues()); + } + + @Test + void testSingleFieldWithNotPostfixOperator() { + MultivaluedMap queryParams = new MultivaluedHashMap<>(); + queryParams.add("filter[book.title][notpostfix]", "abc,def"); + + Map> predicates = parse(queryParams); + assertTrue(predicates.containsKey("book")); + + FilterPredicate predicate = predicates.get("book").iterator().next(); + assertEquals("title", predicate.getField()); + assertEquals(Operator.NOT_POSTFIX, predicate.getOperator()); + assertEquals(Arrays.asList("abc", "def"), predicate.getValues()); + } + + @Test + void testSingleFieldWithNotInfixOperator() { + MultivaluedMap queryParams = new MultivaluedHashMap<>(); + queryParams.add("filter[book.title][notinfix]", "abc,def"); + + Map> predicates = parse(queryParams); + assertTrue(predicates.containsKey("book")); + + FilterPredicate predicate = predicates.get("book").iterator().next(); + assertEquals("title", predicate.getField()); + assertEquals(Operator.NOT_INFIX, predicate.getOperator()); + assertEquals(Arrays.asList("abc", "def"), predicate.getValues()); + } + @Test void testMissingType() { MultivaluedMap queryParams = new MultivaluedHashMap<>(); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/filter/OperatorTest.java b/elide-core/src/test/java/com/yahoo/elide/core/filter/OperatorTest.java index d5c2bb8903..b4eb72c528 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/filter/OperatorTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/filter/OperatorTest.java @@ -9,6 +9,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; + import com.yahoo.elide.core.Path; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.dictionary.EntityDictionary; @@ -19,9 +20,11 @@ import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.DefaultClassScanner; import com.yahoo.elide.core.utils.coerce.CoerceUtil; + import example.Address; import example.Author; import example.Book; + import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -35,7 +38,7 @@ import java.util.function.Predicate; public class OperatorTest { - private EntityDictionary dictionary; + private final EntityDictionary dictionary; private final RequestScope requestScope; private Author author; private Predicate fn; @@ -47,7 +50,7 @@ public TestEntityDictionary(Map> checks) { Collections.emptyMap(), //role checks EntityDictionary.DEFAULT_INJECTOR, CoerceUtil::lookup, - Collections.emptySet(), //excluded entities + Collections.emptySet(), //excluded entities DefaultClassScanner.getInstance() ); } @@ -160,7 +163,7 @@ public void complexAttributeTest() throws Exception { fn = Operator.IN.contextualize(constructPath(Author.class, "homeAddress.street1"), Arrays.asList("Foo", "Bar"), requestScope); assertTrue(fn.test(author)); - fn = Operator.IN.contextualize(constructPath(Author.class, "homeAddress.street1"), Arrays.asList("Baz"), requestScope); + fn = Operator.IN.contextualize(constructPath(Author.class, "homeAddress.street1"), List.of("Baz"), requestScope); assertFalse(fn.test(author)); } @@ -183,7 +186,7 @@ public void isemptyAndNotemptyTest() throws Exception { //name is null and books are null author.setBooks(null); - author.setAwards(Arrays.asList()); + author.setAwards(List.of()); fn = Operator.ISEMPTY.contextualize(constructPath(Author.class, "awards"), null, requestScope); assertTrue(fn.test(author)); fn = Operator.ISEMPTY.contextualize(constructPath(Author.class, "books"), null, requestScope); @@ -204,7 +207,7 @@ public void inOperatorTraversingToManyRelationshipTest() throws Exception { author2.setName("Jane"); book.setAuthors(Arrays.asList(author1, author2)); - fn = Operator.IN.contextualize(constructPath(Book.class, "authors.name"), Arrays.asList("Jon"), requestScope); + fn = Operator.IN.contextualize(constructPath(Book.class, "authors.name"), List.of("Jon"), requestScope); assertTrue(fn.test(book)); fn = Operator.IN.contextualize(constructPath(Book.class, "authors.name"), Arrays.asList("Nobody", "Jon"), requestScope); @@ -216,7 +219,7 @@ public void inOperatorTraversingToManyRelationshipTest() throws Exception { fn = Operator.IN.contextualize(constructPath(Book.class, "authors.name"), Arrays.asList("Nobody1", "Nobody2"), requestScope); assertFalse(fn.test(book)); - fn = Operator.NOT.contextualize(constructPath(Book.class, "authors.name"), Arrays.asList("Jon"), requestScope); + fn = Operator.NOT.contextualize(constructPath(Book.class, "authors.name"), List.of("Jon"), requestScope); assertFalse(fn.test(book)); fn = Operator.NOT.contextualize(constructPath(Book.class, "authors.name"), Arrays.asList("Nobody", "Jon"), requestScope); @@ -278,14 +281,14 @@ public void memberOfTest() throws Exception { author.setAwards(Arrays.asList("Booker Prize", "National Book Awards")); author.getBooks().add(new Book()); - fn = Operator.HASMEMBER.contextualize(constructPath(Author.class, "awards"), Arrays.asList("Booker Prize"), requestScope); + fn = Operator.HASMEMBER.contextualize(constructPath(Author.class, "awards"), List.of("Booker Prize"), requestScope); assertTrue(fn.test(author)); - fn = Operator.HASMEMBER.contextualize(constructPath(Author.class, "awards"), Arrays.asList(""), requestScope); + fn = Operator.HASMEMBER.contextualize(constructPath(Author.class, "awards"), List.of(""), requestScope); assertFalse(fn.test(author)); - fn = Operator.HASNOMEMBER.contextualize(constructPath(Author.class, "awards"), Arrays.asList("National Book Awards"), requestScope); + fn = Operator.HASNOMEMBER.contextualize(constructPath(Author.class, "awards"), List.of("National Book Awards"), requestScope); assertFalse(fn.test(author)); - fn = Operator.HASNOMEMBER.contextualize(constructPath(Author.class, "awards"), Arrays.asList("1"), requestScope); + fn = Operator.HASNOMEMBER.contextualize(constructPath(Author.class, "awards"), List.of("1"), requestScope); assertTrue(fn.test(author)); assertThrows( @@ -323,6 +326,30 @@ public void prefixAndPostfixAndInfixTest() throws Exception { fn = Operator.POSTFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("error"), requestScope); assertFalse(fn.test(author)); + // When notprefix, notinfix, notpostfix are correctly matched + fn = Operator.NOT_PREFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("Author"), requestScope); + assertFalse(fn.test(author)); + fn = Operator.NOT_INFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("For"), requestScope); + assertFalse(fn.test(author)); + fn = Operator.NOT_POSTFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("Test"), requestScope); + assertFalse(fn.test(author)); + + // When notprefix, notinfix, notpostfix are correctly matched if case-insensitive + fn = Operator.NOT_PREFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("author"), requestScope); + assertTrue(fn.test(author)); + fn = Operator.NOT_INFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("for"), requestScope); + assertTrue(fn.test(author)); + fn = Operator.NOT_POSTFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("test"), requestScope); + assertTrue(fn.test(author)); + + // When notprefix, notinfix, notpostfix are not matched + fn = Operator.NOT_PREFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("error"), requestScope); + assertTrue(fn.test(author)); + fn = Operator.NOT_INFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("error"), requestScope); + assertTrue(fn.test(author)); + fn = Operator.NOT_POSTFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("error"), requestScope); + assertTrue(fn.test(author)); + // When values is null author.setName(null); fn = Operator.PREFIX.contextualize(constructPath(Author.class, "name"), Collections.singletonList("Author"), requestScope); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/DefaultFilterDialectTest.java b/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/DefaultFilterDialectTest.java index 159fe3a590..9b27796cf8 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/DefaultFilterDialectTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/DefaultFilterDialectTest.java @@ -123,7 +123,7 @@ public void testTypedExpressionParsing() throws Exception { } @Test - public void testInvalidType() throws ParseException { + public void testInvalidType() { MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.add( @@ -135,7 +135,7 @@ public void testInvalidType() throws ParseException { } @Test - public void testInvalidAttribute() throws ParseException { + public void testInvalidAttribute() { MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.add( diff --git a/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialectTest.java b/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialectTest.java index be9431ea02..a3fd81cbb3 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialectTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialectTest.java @@ -54,7 +54,7 @@ public static void init() { dictionary.bindEntity(StringId.class); dictionary.bindEntity(Job.class); dictionary.bindEntity(PrimitiveId.class); - dialect = new RSQLFilterDialect(dictionary); + dialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); } @Test diff --git a/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialectWithFIQLCompliantStrategyTest.java b/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialectWithFIQLCompliantStrategyTest.java index 54b16da0d8..1da7f40595 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialectWithFIQLCompliantStrategyTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/filter/dialect/RSQLFilterDialectWithFIQLCompliantStrategyTest.java @@ -30,7 +30,10 @@ public static void init() { dictionary.bindEntity(Author.class); dictionary.bindEntity(Book.class); - dialect = new RSQLFilterDialect(dictionary, new CaseSensitivityStrategy.FIQLCompliant()); + dialect = RSQLFilterDialect.builder() + .dictionary(dictionary) + .caseSensitivityStrategy(new CaseSensitivityStrategy.FIQLCompliant()) + .build(); } @Test diff --git a/elide-core/src/test/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitorTest.java b/elide-core/src/test/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitorTest.java index bc8d9992b9..f16372c6ab 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitorTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/filter/expression/ExpressionScopingVisitorTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/test/java/com/yahoo/elide/core/filter/expression/InMemoryFilterExecutorTest.java b/elide-core/src/test/java/com/yahoo/elide/core/filter/expression/InMemoryFilterExecutorTest.java index 4deef07782..b55a6389e2 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/filter/expression/InMemoryFilterExecutorTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/filter/expression/InMemoryFilterExecutorTest.java @@ -75,10 +75,11 @@ public TestEntityDictionary(Map checks) { Collections.emptyMap(), //role checks EntityDictionary.DEFAULT_INJECTOR, CoerceUtil::lookup, - Collections.emptySet(), //excluded entities + Collections.emptySet(), //excluded entities DefaultClassScanner.getInstance() ); } + @Override public Type lookupBoundClass(Type objClass) { // Special handling for mocked Book class which has Entity annotation diff --git a/elide-core/src/test/java/com/yahoo/elide/core/filter/visitors/VerifyFieldAccessFilterExpressionVisitorTest.java b/elide-core/src/test/java/com/yahoo/elide/core/filter/visitors/VerifyFieldAccessFilterExpressionVisitorTest.java index a3c56ffcbb..77e9e9f7ec 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/filter/visitors/VerifyFieldAccessFilterExpressionVisitorTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/filter/visitors/VerifyFieldAccessFilterExpressionVisitorTest.java @@ -21,6 +21,7 @@ import com.yahoo.elide.core.Path.PathElement; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.ForbiddenAccessException; @@ -184,7 +185,7 @@ public void testReject() { @Test public void testShortCircuitReject() throws Exception { - RSQLFilterDialect dialect = new RSQLFilterDialect(scope.getDictionary()); + RSQLFilterDialect dialect = RSQLFilterDialect.builder().dictionary(scope.getDictionary()).build(); FilterExpression expression = dialect.parseFilterExpression("genre==foo", ClassType.of(Book.class), true); @@ -209,7 +210,7 @@ public void testShortCircuitReject() throws Exception { @Test public void testShortCircuitRejectDeferThenFail() throws Exception { - RSQLFilterDialect dialect = new RSQLFilterDialect(scope.getDictionary()); + RSQLFilterDialect dialect = RSQLFilterDialect.builder().dictionary(scope.getDictionary()).build(); FilterExpression expression = dialect.parseFilterExpression("authors.homeAddress==main", ClassType.of(Book.class), true); @@ -240,12 +241,12 @@ public void testShortCircuitRejectDeferThenFail() throws Exception { verify(permissionExecutor, never()).checkSpecificFieldPermissionsDeferred(any(), any(), any(), any()); verify(permissionExecutor, times(2)).checkUserPermissions(any(), any(), isA(String.class)); verify(permissionExecutor, times(1)).handleFilterJoinReject(any(), any(), any()); - verify(tx, never()).getRelation(any(), any(), any(), any()); + verify(tx, never()).getToManyRelation(any(), any(), any(), any()); } @Test public void testShortCircuitDeferred() throws Exception { - RSQLFilterDialect dialect = new RSQLFilterDialect(scope.getDictionary()); + RSQLFilterDialect dialect = RSQLFilterDialect.builder().dictionary(scope.getDictionary()).build(); FilterExpression expression = dialect.parseFilterExpression("genre==foo", ClassType.of(Book.class), true); @@ -272,7 +273,7 @@ public void testShortCircuitDeferred() throws Exception { @Test public void testShortCircuitPass() throws Exception { - RSQLFilterDialect dialect = new RSQLFilterDialect(scope.getDictionary()); + RSQLFilterDialect dialect = RSQLFilterDialect.builder().dictionary(scope.getDictionary()).build(); FilterExpression expression = dialect.parseFilterExpression("authors.name==foo", ClassType.of(Book.class), true); @@ -297,12 +298,12 @@ public void testShortCircuitPass() throws Exception { verify(permissionExecutor, never()).checkSpecificFieldPermissions(resource, null, ReadPermission.class, GENRE); verify(permissionExecutor, times(2)).checkUserPermissions(any(), any(), isA(String.class)); verify(permissionExecutor, never()).handleFilterJoinReject(any(), any(), any()); - verify(tx, never()).getRelation(any(), any(), any(), any()); + verify(tx, never()).getToManyRelation(any(), any(), any(), any()); } @Test public void testUserChecksDeferred() throws Exception { - RSQLFilterDialect dialect = new RSQLFilterDialect(scope.getDictionary()); + RSQLFilterDialect dialect = RSQLFilterDialect.builder().dictionary(scope.getDictionary()).build(); FilterExpression expression = dialect.parseFilterExpression("authors.homeAddress==main", ClassType.of(Book.class), true); @@ -328,7 +329,8 @@ public void testUserChecksDeferred() throws Exception { when(permissionExecutor.checkSpecificFieldPermissions(resourceAuthor, null, ReadPermission.class, HOME)) .thenThrow(ForbiddenAccessException.class); - when(tx.getRelation(eq(tx), eq(book), any(), eq(scope))).thenReturn(book.getAuthors()); + when(tx.getToManyRelation(eq(tx), eq(book), any(), eq(scope))) + .thenReturn(new DataStoreIterableBuilder(book.getAuthors()).build()); VerifyFieldAccessFilterExpressionVisitor visitor = new VerifyFieldAccessFilterExpressionVisitor(resource); // restricted HOME field @@ -341,12 +343,12 @@ public void testUserChecksDeferred() throws Exception { verify(permissionExecutor, times(1)).checkSpecificFieldPermissions(resourceAuthor, null, ReadPermission.class, HOME); verify(permissionExecutor, times(2)).checkUserPermissions(any(), any(), isA(String.class)); verify(permissionExecutor, times(1)).handleFilterJoinReject(any(), any(), any()); - verify(tx, times(1)).getRelation(eq(tx), eq(book), any(), eq(scope)); + verify(tx, times(1)).getToManyRelation(eq(tx), eq(book), any(), eq(scope)); } @Test public void testBypassReadonlyFilterRestriction() throws Exception { - RSQLFilterDialect dialect = new RSQLFilterDialect(scope.getDictionary()); + RSQLFilterDialect dialect = RSQLFilterDialect.builder().dictionary(scope.getDictionary()).build(); FilterExpression expression = dialect.parseFilterExpression("authors.name==foo", ClassType.of(Book.class), true); @@ -366,12 +368,12 @@ public void testBypassReadonlyFilterRestriction() throws Exception { verify(permissionExecutor, never()).checkSpecificFieldPermissions(any(), any(), any(), any()); verify(permissionExecutor, never()).checkUserPermissions(any(), any(), isA(String.class)); verify(permissionExecutor, never()).handleFilterJoinReject(any(), any(), any()); - verify(tx, never()).getRelation(any(), any(), any(), any()); + verify(tx, never()).getToManyRelation(any(), any(), any(), any()); } @Test public void testCustomFilterJoin() throws Exception { - RSQLFilterDialect dialect = new RSQLFilterDialect(scope.getDictionary()); + RSQLFilterDialect dialect = RSQLFilterDialect.builder().dictionary(scope.getDictionary()).build(); FilterExpression expression = dialect.parseFilterExpression("genre==foo", ClassType.of(Book.class), true); @@ -412,6 +414,6 @@ public void testCustomFilterJoin() throws Exception { verify(permissionExecutor, times(1)).checkSpecificFieldPermissions(resource, null, ReadPermission.class, GENRE); verify(permissionExecutor, never()).checkUserPermissions(any(), any(), isA(String.class)); verify(permissionExecutor, times(1)).handleFilterJoinReject(any(), any(), any()); - verify(tx, never()).getRelation(any(), any(), any(), any()); + verify(tx, never()).getToManyRelation(any(), any(), any(), any()); } } diff --git a/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/ErrorTestModel.java b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/ErrorTestModel.java new file mode 100644 index 0000000000..4da3396f9c --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/ErrorTestModel.java @@ -0,0 +1,40 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.core.lifecycle; + +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRECOMMIT; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.security.ChangeSpec; + +import java.util.Optional; +import javax.persistence.Id; + +/** + * Tests life cycle hooks which raise errors. + */ +@Include(name = "errorTestModel") +@LifeCycleHookBinding(hook = ErrorTestModel.ErrorHook.class, operation = CREATE, phase = PRECOMMIT) +public class ErrorTestModel { + + @Id + private String id; + + private String field; + + static class ErrorHook implements LifeCycleHook { + @Override + public void execute(LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + ErrorTestModel elideEntity, + com.yahoo.elide.core.security.RequestScope requestScope, + Optional changes) { + throw new BadRequestException("Invalid"); + } + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/FieldTestModel.java b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/FieldTestModel.java index a20e4ad1df..76a26bcdcd 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/FieldTestModel.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/FieldTestModel.java @@ -7,7 +7,6 @@ import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.DELETE; -import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.POSTCOMMIT; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRECOMMIT; @@ -16,6 +15,7 @@ import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.LifeCycleHookBinding; import com.yahoo.elide.core.security.ChangeSpec; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; @@ -44,10 +44,8 @@ @LifeCycleHookBinding(hook = FieldTestModel.ClassPreFlushHook.class, operation = UPDATE, phase = PREFLUSH) @LifeCycleHookBinding(hook = FieldTestModel.ClassPreCommitHook.class, operation = UPDATE, phase = PRECOMMIT) @LifeCycleHookBinding(hook = FieldTestModel.ClassPostCommitHook.class, operation = UPDATE, phase = POSTCOMMIT) -@LifeCycleHookBinding(hook = FieldTestModel.ClassPreSecurityHook.class, operation = READ, phase = PRESECURITY) -@LifeCycleHookBinding(hook = FieldTestModel.ClassPreFlushHook.class, operation = READ, phase = PREFLUSH) -@LifeCycleHookBinding(hook = FieldTestModel.ClassPreCommitHook.class, operation = READ, phase = PRECOMMIT) -@LifeCycleHookBinding(hook = FieldTestModel.ClassPostCommitHook.class, operation = READ, phase = POSTCOMMIT) + +@EqualsAndHashCode public class FieldTestModel { @Id @@ -67,10 +65,6 @@ public class FieldTestModel { @LifeCycleHookBinding(hook = FieldTestModel.AttributePreFlushHook.class, operation = UPDATE, phase = PREFLUSH) @LifeCycleHookBinding(hook = FieldTestModel.AttributePreCommitHook.class, operation = UPDATE, phase = PRECOMMIT) @LifeCycleHookBinding(hook = FieldTestModel.AttributePostCommitHook.class, operation = UPDATE, phase = POSTCOMMIT) - @LifeCycleHookBinding(hook = FieldTestModel.AttributePreSecurityHook.class, operation = READ, phase = PRESECURITY) - @LifeCycleHookBinding(hook = FieldTestModel.AttributePreFlushHook.class, operation = READ, phase = PREFLUSH) - @LifeCycleHookBinding(hook = FieldTestModel.AttributePreCommitHook.class, operation = READ, phase = PRECOMMIT) - @LifeCycleHookBinding(hook = FieldTestModel.AttributePostCommitHook.class, operation = READ, phase = POSTCOMMIT) private String field; @Getter @@ -88,10 +82,6 @@ public class FieldTestModel { @LifeCycleHookBinding(hook = FieldTestModel.RelationPreFlushHook.class, operation = UPDATE, phase = PREFLUSH) @LifeCycleHookBinding(hook = FieldTestModel.RelationPreCommitHook.class, operation = UPDATE, phase = PRECOMMIT) @LifeCycleHookBinding(hook = FieldTestModel.RelationPostCommitHook.class, operation = UPDATE, phase = POSTCOMMIT) - @LifeCycleHookBinding(hook = FieldTestModel.RelationPreSecurityHook.class, operation = READ, phase = PRESECURITY) - @LifeCycleHookBinding(hook = FieldTestModel.RelationPreFlushHook.class, operation = READ, phase = PREFLUSH) - @LifeCycleHookBinding(hook = FieldTestModel.RelationPreCommitHook.class, operation = READ, phase = PRECOMMIT) - @LifeCycleHookBinding(hook = FieldTestModel.RelationPostCommitHook.class, operation = READ, phase = POSTCOMMIT) private Set models = new HashSet<>(); static class ClassPreSecurityHook implements LifeCycleHook { diff --git a/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LegacyTestModel.java b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LegacyTestModel.java index 1b9929089b..4e62738696 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LegacyTestModel.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LegacyTestModel.java @@ -12,9 +12,6 @@ import com.yahoo.elide.annotation.OnDeletePostCommit; import com.yahoo.elide.annotation.OnDeletePreCommit; import com.yahoo.elide.annotation.OnDeletePreSecurity; -import com.yahoo.elide.annotation.OnReadPostCommit; -import com.yahoo.elide.annotation.OnReadPreCommit; -import com.yahoo.elide.annotation.OnReadPreSecurity; import com.yahoo.elide.annotation.OnUpdatePostCommit; import com.yahoo.elide.annotation.OnUpdatePreCommit; import com.yahoo.elide.annotation.OnUpdatePreSecurity; @@ -64,18 +61,6 @@ public void fieldUpdatePreCommit() { public void fieldUpdatePreSecurity() { } - @OnReadPostCommit("field") - public void fieldReadPostCommit() { - } - - @OnReadPreCommit("field") - public void fieldReadPreCommit() { - } - - @OnReadPreSecurity("field") - public void fieldReadPreSecurity() { - } - @OnDeletePostCommit public void classDeletePostCommit() { } @@ -112,18 +97,6 @@ public void classUpdatePreCommit() { public void classUpdatePreSecurity() { } - @OnReadPostCommit - public void classReadPostCommit() { - } - - @OnReadPreCommit - public void classReadPreCommit() { - } - - @OnReadPreSecurity - public void classReadPreSecurity() { - } - @OnCreatePreSecurity("*") public void classCreatePreCommitAllUpdates() { } @@ -131,9 +104,6 @@ public void classCreatePreCommitAllUpdates() { @OnCreatePostCommit("field") @OnCreatePreCommit @OnCreatePreSecurity("field") - @OnReadPostCommit - @OnReadPreCommit("field") - @OnReadPreSecurity @OnUpdatePostCommit("field") @OnUpdatePreCommit @OnUpdatePreSecurity("field") @@ -143,9 +113,6 @@ public void fieldMultiple() { @OnCreatePostCommit @OnCreatePreCommit("field") @OnCreatePreSecurity - @OnReadPostCommit("field") - @OnReadPreCommit - @OnReadPreSecurity("field") @OnUpdatePostCommit @OnUpdatePreCommit("field") @OnUpdatePreSecurity diff --git a/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LifeCycleTest.java b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LifeCycleTest.java index d24db41db8..6f2a5c7b27 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LifeCycleTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/lifecycle/LifeCycleTest.java @@ -9,7 +9,6 @@ import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.DELETE; -import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.POSTCOMMIT; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRECOMMIT; @@ -37,8 +36,11 @@ import com.yahoo.elide.ElideSettingsBuilder; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.TransactionRegistry; import com.yahoo.elide.core.audit.AuditLogger; import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.dictionary.TestDictionary; @@ -52,9 +54,11 @@ import com.yahoo.elide.core.type.ClassType; import com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.util.Arrays; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.UUID; import javax.validation.ConstraintViolationException; @@ -75,6 +79,25 @@ public class LifeCycleTest { dictionary.bindEntity(FieldTestModel.class); dictionary.bindEntity(PropertyTestModel.class); dictionary.bindEntity(LegacyTestModel.class); + dictionary.bindEntity(ErrorTestModel.class); + } + + @Test + public void testLifecycleError() throws Exception { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + ErrorTestModel mockModel = mock(ErrorTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = "{\"data\": {\"type\":\"errorTestModel\",\"id\":\"1\",\"attributes\": {\"field\":\"Foo\"}}}"; + + when(store.beginTransaction()).thenReturn(tx); + when(tx.createNewObject(eq(ClassType.of(ErrorTestModel.class)), any())).thenReturn(mockModel); + + ElideResponse response = elide.post(baseUrl, "/errorTestModel", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_BAD_REQUEST, response.getResponseCode()); + assertEquals("{\"errors\":[{\"detail\":\"Invalid\"}]}", response.getBody()); } @Test @@ -93,10 +116,6 @@ public void testElideCreate() throws Exception { ElideResponse response = elide.post(baseUrl, "/testModel", body, null, NO_VERSION); assertEquals(HttpStatus.SC_CREATED, response.getResponseCode()); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PREFLUSH)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRESECURITY)); verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PREFLUSH)); verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRECOMMIT)); @@ -107,10 +126,6 @@ public void testElideCreate() throws Exception { verify(mockModel, times(2)).classAllFieldsCallback(any(), any()); verify(mockModel, times(2)).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PREFLUSH), any()); verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); @@ -118,10 +133,6 @@ public void testElideCreate() throws Exception { verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRESECURITY), any()); verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PREFLUSH), any()); verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); @@ -152,14 +163,11 @@ public void testLegacyElideCreate() throws Exception { ElideResponse response = elide.post(baseUrl, "/legacyTestModel", body, null, NO_VERSION); assertEquals(HttpStatus.SC_CREATED, response.getResponseCode()); - verify(mockModel, times(1)).classReadPreSecurity(); - verify(mockModel, times(1)).classReadPreCommit(); - verify(mockModel, times(1)).classReadPostCommit(); verify(mockModel, times(1)).classCreatePreCommitAllUpdates(); verify(mockModel, times(1)).classCreatePreSecurity(); verify(mockModel, times(1)).classCreatePreCommit(); verify(mockModel, times(1)).classCreatePostCommit(); - verify(mockModel, times(6)).classMultiple(); + verify(mockModel, times(3)).classMultiple(); verify(mockModel, never()).classUpdatePreCommit(); verify(mockModel, never()).classUpdatePostCommit(); verify(mockModel, never()).classUpdatePreSecurity(); @@ -167,13 +175,10 @@ public void testLegacyElideCreate() throws Exception { verify(mockModel, never()).classDeletePostCommit(); verify(mockModel, never()).classDeletePreSecurity(); - verify(mockModel, times(1)).fieldReadPreSecurity(); - verify(mockModel, times(1)).fieldReadPreCommit(); - verify(mockModel, times(1)).fieldReadPostCommit(); verify(mockModel, times(1)).fieldCreatePreSecurity(); verify(mockModel, times(1)).fieldCreatePreCommit(); verify(mockModel, times(1)).fieldCreatePostCommit(); - verify(mockModel, times(6)).fieldMultiple(); + verify(mockModel, times(3)).fieldMultiple(); verify(mockModel, never()).fieldUpdatePreCommit(); verify(mockModel, never()).fieldUpdatePostCommit(); verify(mockModel, never()).fieldUpdatePreSecurity(); @@ -235,26 +240,14 @@ public void testElideGet() throws Exception { verify(mockModel, never()).classAllFieldsCallback(any(), any()); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PREFLUSH)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); verify(mockModel, never()).classCallback(eq(CREATE), any()); verify(mockModel, never()).classCallback(eq(UPDATE), any()); verify(mockModel, never()).classCallback(eq(DELETE), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); @@ -280,10 +273,7 @@ public void testLegacyElideGet() throws Exception { ElideResponse response = elide.get(baseUrl, "/legacyTestModel/1", queryParams, null, NO_VERSION); assertEquals(HttpStatus.SC_OK, response.getResponseCode()); - verify(mockModel, times(1)).classReadPreSecurity(); - verify(mockModel, times(1)).classReadPreCommit(); - verify(mockModel, times(1)).classReadPostCommit(); - verify(mockModel, times(3)).classMultiple(); + verify(mockModel, never()).classMultiple(); verify(mockModel, never()).classUpdatePreSecurity(); verify(mockModel, never()).classUpdatePreCommit(); verify(mockModel, never()).classUpdatePostCommit(); @@ -295,10 +285,7 @@ public void testLegacyElideGet() throws Exception { verify(mockModel, never()).classDeletePreCommit(); verify(mockModel, never()).classDeletePostCommit(); - verify(mockModel, times(1)).fieldReadPreSecurity(); - verify(mockModel, times(1)).fieldReadPreCommit(); - verify(mockModel, times(1)).fieldReadPostCommit(); - verify(mockModel, times(3)).fieldMultiple(); + verify(mockModel, never()).fieldMultiple(); verify(mockModel, never()).fieldUpdatePreSecurity(); verify(mockModel, never()).fieldUpdatePreCommit(); verify(mockModel, never()).fieldUpdatePostCommit(); @@ -331,18 +318,10 @@ public void testElideGetSparse() throws Exception { verify(mockModel, never()).classAllFieldsCallback(any(), any()); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PREFLUSH)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); verify(mockModel, never()).classCallback(eq(CREATE), any()); verify(mockModel, never()).classCallback(eq(UPDATE), any()); verify(mockModel, never()).classCallback(eq(DELETE), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); @@ -393,26 +372,14 @@ public void testElideGetRelationship() throws Exception { verify(mockModel, never()).classAllFieldsCallback(any(), any()); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PREFLUSH)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); verify(mockModel, never()).classCallback(eq(CREATE), any()); verify(mockModel, never()).classCallback(eq(UPDATE), any()); verify(mockModel, never()).classCallback(eq(DELETE), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); @@ -443,7 +410,6 @@ public void testElidePatch() throws Exception { verify(mockModel, never()).classAllFieldsCallback(any(), any()); - verify(mockModel, never()).classCallback(eq(READ), any()); verify(mockModel, never()).classCallback(eq(CREATE), any()); verify(mockModel, never()).classCallback(eq(DELETE), any()); verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); @@ -451,7 +417,6 @@ public void testElidePatch() throws Exception { verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); - verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); @@ -459,7 +424,6 @@ public void testElidePatch() throws Exception { verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); - verify(mockModel, never()).relationCallback(eq(READ), any(), any()); verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); @@ -471,6 +435,43 @@ public void testElidePatch() throws Exception { verify(tx).close(); } + @Test + public void testElidePatchRelationshipAddMultiple() { + DataStore store = mock(DataStore.class); + DataStoreTransaction tx = mock(DataStoreTransaction.class); + FieldTestModel parent = mock(FieldTestModel.class); + FieldTestModel child1 = mock(FieldTestModel.class); + FieldTestModel child2 = mock(FieldTestModel.class); + FieldTestModel child3 = mock(FieldTestModel.class); + + Elide elide = getElide(store, dictionary, MOCK_AUDIT_LOGGER); + + String body = "{\"data\": {\"type\":\"testModel\",\"id\":\"1\",\"relationships\": { \"models\": { \"data\": [ { \"type\": \"testModel\", \"id\": \"2\" }, {\"type\": \"testModel\", \"id\": \"3\" } ] } } } }"; + + dictionary.setValue(parent, "id", "1"); + dictionary.setValue(child1, "id", "2"); + dictionary.setValue(child2, "id", "3"); + dictionary.setValue(child3, "id", "4"); + when(store.beginTransaction()).thenReturn(tx); + when(tx.loadObject(isA(EntityProjection.class), eq("1"), isA(RequestScope.class))).thenReturn(parent); + when(tx.loadObject(isA(EntityProjection.class), eq("2"), isA(RequestScope.class))).thenReturn(child1); + when(tx.loadObject(isA(EntityProjection.class), eq("3"), isA(RequestScope.class))).thenReturn(child2); + when(tx.loadObject(isA(EntityProjection.class), eq("4"), isA(RequestScope.class))).thenReturn(child3); + + DataStoreIterable iterable = new DataStoreIterableBuilder(List.of(child3)).build(); + when(tx.getToManyRelation(any(), any(), isA(Relationship.class), isA(RequestScope.class))).thenReturn(iterable); + + String contentType = JSONAPI_CONTENT_TYPE; + ElideResponse response = elide.patch(baseUrl, contentType, contentType, "/testModel/1", body, null, NO_VERSION); + assertEquals(HttpStatus.SC_NO_CONTENT, response.getResponseCode()); + + verify(parent, times(1)).relationCallback(eq(UPDATE), eq(POSTCOMMIT), notNull()); + + verify(parent, times(4)).classCallback(eq(UPDATE), any()); + verify(parent, never()).classAllFieldsCallback(any(), any()); + verify(parent, never()).attributeCallback(any(), any(), any()); + } + @Test public void testLegacyElidePatch() throws Exception { DataStore store = mock(DataStore.class); @@ -489,9 +490,6 @@ public void testLegacyElidePatch() throws Exception { ElideResponse response = elide.patch(baseUrl, contentType, contentType, "/legacyTestModel/1", body, null, NO_VERSION); assertEquals(HttpStatus.SC_NO_CONTENT, response.getResponseCode()); - verify(mockModel, never()).classReadPreSecurity(); - verify(mockModel, never()).classReadPreCommit(); - verify(mockModel, never()).classReadPostCommit(); verify(mockModel, never()).classCreatePreCommitAllUpdates(); verify(mockModel, never()).classCreatePreSecurity(); verify(mockModel, never()).classCreatePreCommit(); @@ -505,9 +503,6 @@ public void testLegacyElidePatch() throws Exception { verify(mockModel, times(1)).classUpdatePostCommit(); verify(mockModel, times(3)).classMultiple(); - verify(mockModel, never()).fieldReadPreSecurity(); - verify(mockModel, never()).fieldReadPreCommit(); - verify(mockModel, never()).fieldReadPostCommit(); verify(mockModel, never()).fieldCreatePreSecurity(); verify(mockModel, never()).fieldCreatePreCommit(); verify(mockModel, never()).fieldCreatePostCommit(); @@ -543,7 +538,6 @@ public void testElideDelete() throws Exception { verify(mockModel, never()).classCallback(eq(UPDATE), any()); verify(mockModel, never()).classCallback(eq(CREATE), any()); - verify(mockModel, never()).classCallback(eq(READ), any()); verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRESECURITY)); verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PREFLUSH)); @@ -552,12 +546,10 @@ public void testElideDelete() throws Exception { verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); - verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); - verify(mockModel, never()).relationCallback(eq(READ), any(), any()); verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); verify(tx).preCommit(any()); @@ -585,9 +577,6 @@ public void testLegacyElideDelete() throws Exception { verify(mockModel, never()).classUpdatePostCommit(); verify(mockModel, never()).classUpdatePreSecurity(); verify(mockModel, never()).classUpdatePreCommit(); - verify(mockModel, never()).classReadPostCommit(); - verify(mockModel, never()).classReadPreSecurity(); - verify(mockModel, never()).classReadPreCommit(); verify(mockModel, never()).classCreatePreCommitAllUpdates(); verify(mockModel, never()).classCreatePostCommit(); verify(mockModel, never()).classCreatePreSecurity(); @@ -601,9 +590,6 @@ public void testLegacyElideDelete() throws Exception { verify(mockModel, never()).fieldUpdatePostCommit(); verify(mockModel, never()).fieldUpdatePreSecurity(); verify(mockModel, never()).fieldUpdatePreCommit(); - verify(mockModel, never()).fieldReadPostCommit(); - verify(mockModel, never()).fieldReadPreSecurity(); - verify(mockModel, never()).fieldReadPreCommit(); verify(mockModel, never()).fieldCreatePostCommit(); verify(mockModel, never()).fieldCreatePreSecurity(); verify(mockModel, never()).fieldCreatePreCommit(); @@ -616,8 +602,6 @@ public void testLegacyElideDelete() throws Exception { verify(tx).close(); } -//TODO - these need to be rewritten for Elide 5. - @Test public void testElidePatchExtensionCreate() throws Exception { DataStore store = mock(DataStore.class); @@ -638,10 +622,6 @@ public void testElidePatchExtensionCreate() throws Exception { elide.patch(baseUrl, contentType, contentType, "/", body, null, NO_VERSION); assertEquals(HttpStatus.SC_OK, response.getResponseCode()); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PREFLUSH)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); - verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRESECURITY)); verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PREFLUSH)); verify(mockModel, times(1)).classCallback(eq(CREATE), eq(PRECOMMIT)); @@ -652,10 +632,6 @@ public void testElidePatchExtensionCreate() throws Exception { verify(mockModel, times(2)).classAllFieldsCallback(any(), any()); verify(mockModel, times(2)).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PREFLUSH), any()); verify(mockModel, times(1)).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); @@ -663,10 +639,6 @@ public void testElidePatchExtensionCreate() throws Exception { verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, times(1)).relationCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRESECURITY), any()); verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PREFLUSH), any()); verify(mockModel, times(1)).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); @@ -704,10 +676,7 @@ public void testLegacyElidePatchExtensionCreate() throws Exception { verify(mockModel, times(1)).classCreatePreCommit(); verify(mockModel, times(1)).classCreatePreCommitAllUpdates(); verify(mockModel, times(1)).classCreatePostCommit(); - verify(mockModel, times(1)).classReadPreSecurity(); - verify(mockModel, times(1)).classReadPreCommit(); - verify(mockModel, times(1)).classReadPostCommit(); - verify(mockModel, times(6)).classMultiple(); + verify(mockModel, times(3)).classMultiple(); verify(mockModel, never()).classUpdatePreCommit(); verify(mockModel, never()).classUpdatePostCommit(); verify(mockModel, never()).classUpdatePreSecurity(); @@ -718,10 +687,7 @@ public void testLegacyElidePatchExtensionCreate() throws Exception { verify(mockModel, times(1)).fieldCreatePostCommit(); verify(mockModel, times(1)).fieldCreatePreCommit(); verify(mockModel, times(1)).fieldCreatePreSecurity(); - verify(mockModel, times(1)).fieldReadPostCommit(); - verify(mockModel, times(1)).fieldReadPreCommit(); - verify(mockModel, times(1)).fieldReadPreSecurity(); - verify(mockModel, times(6)).fieldMultiple(); + verify(mockModel, times(3)).fieldMultiple(); verify(mockModel, never()).fieldUpdatePreCommit(); verify(mockModel, never()).fieldUpdatePostCommit(); verify(mockModel, never()).fieldUpdatePreSecurity(); @@ -756,10 +722,6 @@ public void failElidePatchExtensionCreate() throws Exception { "[{\"errors\":[{\"detail\":\"Bad Request Body'Patch extension requires all objects to have an assigned ID (temporary or permanent) when assigning relationships.'\",\"status\":\"400\"}]}]", response.getBody()); - verify(mockModel, never()).classCallback(eq(READ), eq(PRESECURITY)); - verify(mockModel, never()).classCallback(eq(READ), eq(PREFLUSH)); - verify(mockModel, never()).classCallback(eq(READ), eq(PRECOMMIT)); - verify(mockModel, never()).classCallback(eq(READ), eq(POSTCOMMIT)); verify(mockModel, never()).classCallback(eq(CREATE), eq(PRESECURITY)); verify(mockModel, never()).classCallback(eq(CREATE), eq(PREFLUSH)); verify(mockModel, never()).classCallback(eq(CREATE), eq(PRECOMMIT)); @@ -770,10 +732,6 @@ public void failElidePatchExtensionCreate() throws Exception { verify(mockModel, never()).classAllFieldsCallback(any(), any()); verify(mockModel, never()).classAllFieldsCallback(eq(CREATE), eq(PRECOMMIT)); - verify(mockModel, never()).attributeCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, never()).attributeCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, never()).attributeCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, never()).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PRESECURITY), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PREFLUSH), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), eq(PRECOMMIT), any()); @@ -781,10 +739,6 @@ public void failElidePatchExtensionCreate() throws Exception { verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - verify(mockModel, never()).relationCallback(eq(READ), eq(PRESECURITY), any()); - verify(mockModel, never()).relationCallback(eq(READ), eq(PREFLUSH), any()); - verify(mockModel, never()).relationCallback(eq(READ), eq(PRECOMMIT), any()); - verify(mockModel, never()).relationCallback(eq(READ), eq(POSTCOMMIT), any()); verify(mockModel, never()).relationCallback(eq(CREATE), eq(PRESECURITY), any()); verify(mockModel, never()).relationCallback(eq(CREATE), eq(PREFLUSH), any()); verify(mockModel, never()).relationCallback(eq(CREATE), eq(PRECOMMIT), any()); @@ -824,7 +778,6 @@ public void testElidePatchExtensionUpdate() throws Exception { verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PREFLUSH)); verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); - verify(mockModel, never()).classCallback(eq(READ), any()); verify(mockModel, never()).classCallback(eq(CREATE), any()); verify(mockModel, never()).classCallback(eq(DELETE), any()); @@ -835,17 +788,17 @@ public void testElidePatchExtensionUpdate() throws Exception { verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PREFLUSH), any()); verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); - verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); - verify(mockModel, never()).relationCallback(eq(READ), any(), any()); verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); verify(tx).preCommit(any()); - verify(tx).loadObject(any(), any(), isA(RequestScope.class)); + + //Twice because the patch extension request is broken into attributes & relationships separately. + verify(tx, times(2)).loadObject(any(), any(), isA(RequestScope.class)); verify(tx).flush(isA(RequestScope.class)); verify(tx).commit(isA(RequestScope.class)); verify(tx).close(); @@ -875,7 +828,6 @@ public void testElidePatchExtensionDelete() throws Exception { verify(mockModel, never()).classCallback(eq(UPDATE), any()); verify(mockModel, never()).classCallback(eq(CREATE), any()); - verify(mockModel, never()).classCallback(eq(READ), any()); verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRESECURITY)); verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PREFLUSH)); verify(mockModel, times(1)).classCallback(eq(DELETE), eq(PRECOMMIT)); @@ -883,14 +835,12 @@ public void testElidePatchExtensionDelete() throws Exception { verify(mockModel, never()).attributeCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); - verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); // TODO - Read should not be called for a delete. verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); - verify(mockModel, never()).relationCallback(eq(READ), any(), any()); verify(tx).preCommit(any()); verify(tx).delete(eq(mockModel), isA(RequestScope.class)); @@ -923,7 +873,6 @@ public void testElidePatchFailure() throws Exception { verify(mockModel, never()).classAllFieldsCallback(any(), any()); - verify(mockModel, never()).classCallback(eq(READ), any()); verify(mockModel, never()).classCallback(eq(CREATE), any()); verify(mockModel, never()).classCallback(eq(DELETE), any()); @@ -932,7 +881,6 @@ public void testElidePatchFailure() throws Exception { verify(mockModel, never()).classCallback(eq(UPDATE), eq(PRECOMMIT)); verify(mockModel, never()).classCallback(eq(UPDATE), eq(POSTCOMMIT)); - verify(mockModel, never()).attributeCallback(eq(READ), any(), any()); verify(mockModel, never()).attributeCallback(eq(CREATE), any(), any()); verify(mockModel, never()).attributeCallback(eq(DELETE), any(), any()); verify(mockModel, times(1)).attributeCallback(eq(UPDATE), eq(PRESECURITY), any()); @@ -940,7 +888,6 @@ public void testElidePatchFailure() throws Exception { verify(mockModel, never()).attributeCallback(eq(UPDATE), eq(PRECOMMIT), any()); verify(mockModel, never()).attributeCallback(eq(UPDATE), eq(POSTCOMMIT), any()); - verify(mockModel, never()).relationCallback(eq(READ), any(), any()); verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); verify(mockModel, never()).relationCallback(eq(DELETE), any(), any()); @@ -1022,10 +969,8 @@ public void testRead() { resource.getAttribute(Attribute.builder().type(String.class).name("field").build()); - verify(mockModel, times(1)).classCallback(any(), any()); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRESECURITY)); - verify(mockModel, times(1)).attributeCallback(any(), any(), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRESECURITY), any()); + verify(mockModel, never()).classCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); verify(mockModel, never()).classAllFieldsCallback(any(), any()); verify(mockModel, never()).relationCallback(any(), any(), any()); @@ -1040,30 +985,24 @@ public void testRead() { clearInvocations(mockModel); scope.runQueuedPreFlushTriggers(); - verify(mockModel, times(1)).classCallback(any(), any()); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PREFLUSH)); - verify(mockModel, times(1)).attributeCallback(any(), any(), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PREFLUSH), any()); + verify(mockModel, never()).classCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); verify(mockModel, never()).classAllFieldsCallback(any(), any()); verify(mockModel, never()).relationCallback(any(), any(), any()); clearInvocations(mockModel); scope.runQueuedPreCommitTriggers(); - verify(mockModel, times(1)).classCallback(any(), any()); - verify(mockModel, times(1)).classCallback(eq(READ), eq(PRECOMMIT)); - verify(mockModel, times(1)).attributeCallback(any(), any(), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(PRECOMMIT), any()); + verify(mockModel, never()).classCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); verify(mockModel, never()).classAllFieldsCallback(any(), any()); verify(mockModel, never()).relationCallback(any(), any(), any()); clearInvocations(mockModel); scope.runQueuedPostCommitTriggers(); - verify(mockModel, times(1)).classCallback(any(), any()); - verify(mockModel, times(1)).classCallback(eq(READ), eq(POSTCOMMIT)); - verify(mockModel, times(1)).attributeCallback(any(), any(), any()); - verify(mockModel, times(1)).attributeCallback(eq(READ), eq(POSTCOMMIT), any()); + verify(mockModel, never()).classCallback(any(), any()); + verify(mockModel, never()).attributeCallback(any(), any(), any()); verify(mockModel, never()).classAllFieldsCallback(any(), any()); verify(mockModel, never()).relationCallback(any(), any(), any()); } @@ -1196,9 +1135,8 @@ public void testRelationshipUpdate() { verify(mockModel, times(1)).classCallback(any(), any()); verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRESECURITY)); - //TODO - this should be only called once. THis is called twice because the mock has a null collection. - verify(mockModel, times(2)).relationCallback(any(), any(), any()); - verify(mockModel, times(2)).relationCallback(eq(UPDATE), eq(PRESECURITY), notNull()); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(UPDATE), eq(PRESECURITY), notNull()); verify(mockModel, never()).attributeCallback(any(), any(), any()); verify(mockModel, never()).classAllFieldsCallback(any(), any()); @@ -1216,9 +1154,8 @@ public void testRelationshipUpdate() { verify(mockModel, times(1)).classCallback(any(), any()); verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PREFLUSH)); - //TODO - this should be only called once. THis is called twice because the mock has a null collection. - verify(mockModel, times(2)).relationCallback(any(), any(), any()); - verify(mockModel, times(2)).relationCallback(eq(UPDATE), eq(PREFLUSH), notNull()); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(UPDATE), eq(PREFLUSH), notNull()); clearInvocations(mockModel); scope.runQueuedPreCommitTriggers(); @@ -1228,9 +1165,8 @@ public void testRelationshipUpdate() { verify(mockModel, times(1)).classCallback(any(), any()); verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(PRECOMMIT)); - //TODO - this should be only called once. - verify(mockModel, times(2)).relationCallback(any(), any(), any()); - verify(mockModel, times(2)).relationCallback(eq(UPDATE), eq(PRECOMMIT), notNull()); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(UPDATE), eq(PRECOMMIT), notNull()); clearInvocations(mockModel); scope.getPermissionExecutor().executeCommitChecks(); @@ -1241,9 +1177,8 @@ public void testRelationshipUpdate() { verify(mockModel, times(1)).classCallback(any(), any()); verify(mockModel, times(1)).classCallback(eq(UPDATE), eq(POSTCOMMIT)); - //TODO - this should be only called once. - verify(mockModel, times(2)).relationCallback(any(), any(), any()); - verify(mockModel, times(2)).relationCallback(eq(UPDATE), eq(POSTCOMMIT), notNull()); + verify(mockModel, times(1)).relationCallback(any(), any(), any()); + verify(mockModel, times(1)).relationCallback(eq(UPDATE), eq(POSTCOMMIT), notNull()); } @Test @@ -1253,13 +1188,15 @@ public void testAddToCollectionTrigger() { RequestScope scope = buildRequestScope(dictionary, tx); when(tx.createNewObject(ClassType.of(PropertyTestModel.class), scope)).thenReturn(mockModel); - PropertyTestModel modelToAdd = mock(PropertyTestModel.class); + PropertyTestModel modelToAdd1 = mock(PropertyTestModel.class); + PropertyTestModel modelToAdd2 = mock(PropertyTestModel.class); //First we test adding to a newly created object. PersistentResource resource = PersistentResource.createObject(ClassType.of(PropertyTestModel.class), scope, Optional.of("1")); - PersistentResource resourceToAdd = new PersistentResource(modelToAdd, scope.getUUIDFor(mockModel), scope); + PersistentResource resourceToAdd1 = new PersistentResource(modelToAdd1, scope.getUUIDFor(mockModel), scope); + PersistentResource resourceToAdd2 = new PersistentResource(modelToAdd2, scope.getUUIDFor(mockModel), scope); - resource.updateRelation("models", new HashSet<>(Arrays.asList(resourceToAdd))); + resource.updateRelation("models", new HashSet<>(Arrays.asList(resourceToAdd1, resourceToAdd2))); scope.runQueuedPreSecurityTriggers(); scope.runQueuedPreCommitTriggers(); @@ -1273,7 +1210,7 @@ public void testAddToCollectionTrigger() { resource = new PersistentResource(mockModel, scope.getUUIDFor(mockModel), scope); reset(mockModel); - resource.updateRelation("models", new HashSet<>(Arrays.asList(resourceToAdd))); + resource.updateRelation("models", new HashSet<>(Arrays.asList(resourceToAdd1, resourceToAdd2))); scope.runQueuedPreSecurityTriggers(); scope.runQueuedPreCommitTriggers(); @@ -1292,13 +1229,16 @@ public void testRemoveFromCollectionTrigger() { PropertyTestModel childModel1 = mock(PropertyTestModel.class); PropertyTestModel childModel2 = mock(PropertyTestModel.class); + PropertyTestModel childModel3 = mock(PropertyTestModel.class); when(childModel1.getId()).thenReturn("2"); when(childModel2.getId()).thenReturn("3"); + when(childModel3.getId()).thenReturn("4"); //First we test removing from a newly created object. PersistentResource resource = PersistentResource.createObject(ClassType.of(PropertyTestModel.class), scope, Optional.of("1")); PersistentResource childResource1 = new PersistentResource(childModel1, "2", scope); PersistentResource childResource2 = new PersistentResource(childModel2, "3", scope); + PersistentResource childResource3 = new PersistentResource(childModel3, "3", scope); resource.updateRelation("models", new HashSet<>(Arrays.asList(childResource1, childResource2))); @@ -1307,7 +1247,13 @@ public void testRemoveFromCollectionTrigger() { scope.runQueuedPostCommitTriggers(); verify(mockModel, never()).relationCallback(eq(UPDATE), any(), any()); - verify(mockModel, times(2)).relationCallback(eq(CREATE), eq(POSTCOMMIT), notNull()); + + ArgumentCaptor changes = ArgumentCaptor.forClass(ChangeSpec.class); + verify(mockModel, times(1)).relationCallback(eq(CREATE), + eq(POSTCOMMIT), changes.capture()); + + changes.getValue().getModified().equals(List.of(childModel1, childModel2)); + changes.getValue().getOriginal().equals(List.of()); //Build another resource, scope & reset the mock to do a pure update (no create): scope = buildRequestScope(dictionary, tx); @@ -1320,16 +1266,22 @@ public void testRemoveFromCollectionTrigger() { .name("models") .build(); - when(tx.getRelation(tx, mockModel, relationship, scope)).thenReturn(Arrays.asList(childModel1, childModel2)); + when(tx.getToManyRelation(tx, mockModel, relationship, scope)) + .thenReturn(new DataStoreIterableBuilder(Arrays.asList(childModel1, childModel2)).build()); - resource.updateRelation("models", new HashSet<>(Arrays.asList(childResource1))); + when(mockModel.getModels()).thenReturn(new HashSet<>(Arrays.asList(childModel1, childModel2))); + resource.updateRelation("models", new HashSet<>(Arrays.asList(childResource1, childResource3))); scope.runQueuedPreSecurityTriggers(); scope.runQueuedPreCommitTriggers(); scope.runQueuedPostCommitTriggers(); verify(mockModel, never()).relationCallback(eq(CREATE), any(), any()); - verify(mockModel, times(1)).relationCallback(eq(UPDATE), eq(POSTCOMMIT), notNull()); + + changes = ArgumentCaptor.forClass(ChangeSpec.class); + verify(mockModel, times(1)).relationCallback(eq(UPDATE), eq(POSTCOMMIT), changes.capture()); + changes.getValue().getModified().equals(List.of(childModel1, childModel3)); + changes.getValue().getOriginal().equals(List.of(childModel1, childModel2)); } @Test @@ -1398,7 +1350,8 @@ public void testPreFlushLifecycleHookException() { } private Elide getElide(DataStore dataStore, EntityDictionary dictionary, AuditLogger auditLogger) { - return new Elide(getElideSettings(dataStore, dictionary, auditLogger)); + ElideSettings settings = getElideSettings(dataStore, dictionary, auditLogger); + return new Elide(settings, new TransactionRegistry(), settings.getDictionary().getScanner(), false); } private ElideSettings getElideSettings(DataStore dataStore, EntityDictionary dictionary, AuditLogger auditLogger) { diff --git a/elide-core/src/test/java/com/yahoo/elide/core/security/UpdateOnCreateTest.java b/elide-core/src/test/java/com/yahoo/elide/core/security/UpdateOnCreateTest.java index 2fde57d96c..d805fdbd19 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/security/UpdateOnCreateTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/security/UpdateOnCreateTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/test/java/com/yahoo/elide/core/type/EntityFieldTypeTest.java b/elide-core/src/test/java/com/yahoo/elide/core/type/EntityFieldTypeTest.java new file mode 100644 index 0000000000..bf98dcac0b --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/type/EntityFieldTypeTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.type; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import javax.persistence.ManyToMany; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import javax.persistence.OneToOne; +public class EntityFieldTypeTest { + + static class TestModel { + @OneToOne(targetEntity = String.class) + Object field1; + + @ManyToOne(targetEntity = String.class) + Object field2; + + @OneToMany(targetEntity = String.class) + Object field3; + + @ManyToMany(targetEntity = String.class) + Object field4; + + Object field5; + } + + @Test + public void testGetType() throws Exception { + + Type type = ClassType.of(TestModel.class); + + String [] fieldNames = {"field1", "field2", "field3", "field4", "field5"}; + + for (String fieldName : fieldNames) { + Field field = type.getDeclaredField(fieldName); + assertEquals(ClassType.OBJECT_TYPE, field.getType()); + } + } + + @Test + public void testGetParameterizedReturnType() throws Exception { + + Type type = ClassType.of(TestModel.class); + + String [] fieldNames = {"field1", "field2", "field3", "field4"}; + + for (String fieldName : fieldNames) { + Field field = type.getDeclaredField(fieldName); + assertEquals(ClassType.STRING_TYPE, field.getParameterizedType(type, Optional.of(0))); + } + + Field field = type.getDeclaredField("field5"); + assertEquals(ClassType.OBJECT_TYPE, field.getParameterizedType(type, Optional.of(0))); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/type/EntityMethodTypeTest.java b/elide-core/src/test/java/com/yahoo/elide/core/type/EntityMethodTypeTest.java new file mode 100644 index 0000000000..d659083f73 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/core/type/EntityMethodTypeTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.core.type; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import javax.persistence.ManyToMany; +import javax.persistence.ManyToOne; +import javax.persistence.OneToMany; +import javax.persistence.OneToOne; + +public class EntityMethodTypeTest { + + static class TestModel { + @OneToOne(targetEntity = String.class) + public Object getField1() { + return null; + } + + @ManyToOne(targetEntity = String.class) + public Object getField2() { + return null; + } + + @OneToMany(targetEntity = String.class) + public Object getField3() { + return null; + } + + @ManyToMany(targetEntity = String.class) + public Object getField4() { + return null; + } + + public Object getField5() { + return null; + } + } + + @Test + public void testGetReturnType() throws Exception { + + Type type = ClassType.of(TestModel.class); + + String [] methodNames = {"getField1", "getField2", "getField3", "getField4", "getField5"}; + + for (String methodName : methodNames) { + Method method = type.getMethod(methodName); + assertEquals(ClassType.OBJECT_TYPE, method.getReturnType()); + } + } + + @Test + public void testGetParameterizedReturnType() throws Exception { + + Type type = ClassType.of(TestModel.class); + + String [] methodNames = {"getField1", "getField2", "getField3", "getField4"}; + + for (String methodName : methodNames) { + Method method = type.getMethod(methodName); + assertEquals(ClassType.STRING_TYPE, method.getParameterizedReturnType(type, Optional.of(0))); + } + + Method method = type.getMethod("getField5"); + assertEquals(ClassType.OBJECT_TYPE, method.getParameterizedReturnType(type, Optional.of(0))); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java b/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java index 613e5a8d54..d1eb3339c4 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/utils/ClassScannerTest.java @@ -24,7 +24,7 @@ public ClassScannerTest() { @Test public void testGetAllClasses() { Set> classes = scanner.getAllClasses("com.yahoo.elide.core.utils"); - assertEquals(32, classes.size()); + assertEquals(33, classes.size()); assertTrue(classes.contains(ClassScannerTest.class)); } @@ -38,14 +38,14 @@ public void testGetAnnotatedClasses() { @Test public void testGetAllAnnotatedClasses() { Set> classes = scanner.getAnnotatedClasses(Include.class); - assertEquals(42, classes.size(), "Actual: " + classes); + assertEquals(43, classes.size(), "Actual: " + classes); classes.forEach(cls -> assertTrue(cls.isAnnotationPresent(Include.class))); } @Test public void testGetAnyAnnotatedClasses() { Set> classes = scanner.getAnnotatedClasses(Include.class, Entity.class); - assertEquals(53, classes.size()); + assertEquals(54, classes.size()); for (Class cls : classes) { assertTrue(cls.isAnnotationPresent(Include.class) || cls.isAnnotationPresent(Entity.class)); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/BidirectionalConvertUtilBeanTest.java b/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/BidirectionalConvertUtilBeanTest.java index 22282a4e32..c270fbeaf6 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/BidirectionalConvertUtilBeanTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/BidirectionalConvertUtilBeanTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/CoerceUtilTest.java b/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/CoerceUtilTest.java index 8755b4dbc4..d3fbb9cd1a 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/CoerceUtilTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/CoerceUtilTest.java @@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + import com.yahoo.elide.core.exceptions.InvalidValueException; import com.yahoo.elide.core.utils.coerce.converters.EpochToDateConverter; import com.yahoo.elide.core.utils.coerce.converters.ISO8601DateSerde; @@ -31,6 +33,7 @@ public class CoerceUtilTest { public enum Seasons { WINTER, SPRING } + public enum WeekendDays { SATURDAY, SUNDAY } private static Map oldSerdes = new HashMap<>(); @@ -177,6 +180,15 @@ public void testLongToDate() { assertEquals(new Time(0), timeLong); } + @Test + public void testCustomEnumSerde() { + Serde mockSerde = (Serde) mock(Serde.class); + CoerceUtil.register(WeekendDays.class, mockSerde); + + CoerceUtil.coerce("Monday", WeekendDays.class); + verify(mockSerde, times(1)).deserialize(eq("Monday")); + } + @Test public void testIntToDate() throws Exception { Date date = CoerceUtil.coerce(0, Date.class); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/converters/ElideCustomSerdeRegistrationTest.java b/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/converters/ElideCustomSerdeRegistrationTest.java index 40e1ffbb63..36d007a77b 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/converters/ElideCustomSerdeRegistrationTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/converters/ElideCustomSerdeRegistrationTest.java @@ -48,7 +48,8 @@ public void testRegisterCustomSerde() { InMemoryDataStore store = new InMemoryDataStore(wrapped); ElideSettings elideSettings = new ElideSettingsBuilder(store) .withEntityDictionary(EntityDictionary.builder().build()).build(); - new Elide(elideSettings); + Elide elide = new Elide(elideSettings); + elide.doScans(); assertNotNull(CoerceUtil.lookup(Dummy.class)); assertNotNull(CoerceUtil.lookup(DummyTwo.class)); assertNotNull(CoerceUtil.lookup(DummyThree.class)); diff --git a/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/converters/ISO8601DateSerdeTest.java b/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/converters/ISO8601DateSerdeTest.java index 210c68870f..7d61c6e206 100644 --- a/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/converters/ISO8601DateSerdeTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/core/utils/coerce/converters/ISO8601DateSerdeTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/test/java/com/yahoo/elide/endpoints/ResourceTest.java b/elide-core/src/test/java/com/yahoo/elide/endpoints/ResourceTest.java index 0fdd291e42..e47e0d991b 100644 --- a/elide-core/src/test/java/com/yahoo/elide/endpoints/ResourceTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/endpoints/ResourceTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -34,12 +34,42 @@ public void verifyBase64ID() { "company/QWRkcmVzcyhudW1iZXI9MCwgc3RyZWV0PUJ1bGxpb24gQmx2ZCwgemlwQ29kZT00MDEyMSk="))); } + @Test + public void verifyUnderscoreInPath() { + assertNull(new CoreBaseVisitor().visit(parse( + "foo_bar/"))); + } + + @Test + public void verifyHyphenInPath() { + assertNull(new CoreBaseVisitor().visit(parse( + "foo-bar/"))); + } + @Test public void verifyURLEncodedID() { assertNull(new CoreBaseVisitor().visit(parse( "company/abcdef%201234"))); } + @Test + public void verifyAmpersandId() { + assertNull(new CoreBaseVisitor().visit(parse( + "company/abcdef&234"))); + } + + @Test + public void verifySpaceId() { + assertNull(new CoreBaseVisitor().visit(parse( + "company/abcdef 234"))); + } + + @Test + public void verifyColonId() { + assertNull(new CoreBaseVisitor().visit(parse( + "company/abcdef:234"))); + } + @Test public void parseFailRelationship() { assertThrows( @@ -61,6 +91,35 @@ public void parseFailure() { () -> new CoreBaseVisitor().visit(parse("company/123|apps/2/links/foo"))); } + @Test + + public void invalidNumberStartingPath() { + assertThrows( + ParseCancellationException.class, + () -> new CoreBaseVisitor().visit(parse("3company/"))); + } + + @Test + public void invalidSpaceInPath() { + assertThrows( + ParseCancellationException.class, + () -> new CoreBaseVisitor().visit(parse("comp any/relationships"))); + } + + @Test + public void invalidColonInPath() { + assertThrows( + ParseCancellationException.class, + () -> new CoreBaseVisitor().visit(parse("comp:any/relationships"))); + } + + @Test + public void invalidAmpersandInPath() { + assertThrows( + ParseCancellationException.class, + () -> new CoreBaseVisitor().visit(parse("comp&any/relationships"))); + } + @Test public void wrongPathSeparator() { assertThrows( diff --git a/elide-core/src/test/java/com/yahoo/elide/jsonapi/EntityProjectionMakerTest.java b/elide-core/src/test/java/com/yahoo/elide/jsonapi/EntityProjectionMakerTest.java index 5fd5c48109..3c71857b0a 100644 --- a/elide-core/src/test/java/com/yahoo/elide/jsonapi/EntityProjectionMakerTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/jsonapi/EntityProjectionMakerTest.java @@ -28,12 +28,15 @@ import example.Author; import example.Book; import example.Editor; +import example.Price; import example.Publisher; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import java.util.Collection; +import java.util.Map; +import java.util.Set; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; @@ -68,6 +71,7 @@ public void testRootCollectionNoQueryParams() { .attribute(Attribute.builder().name("language").type(String.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .build()) @@ -127,6 +131,7 @@ public void testRootEntityNoQueryParams() { .attribute(Attribute.builder().name("language").type(String.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .build()) @@ -223,6 +228,9 @@ public void testRelationshipNoQueryParams() { .type(Book.class) .pagination(PaginationImpl.getDefaultPagination(ClassType.of(Book.class))) .build()) + .relationship("products", EntityProjection.builder() + .type(Book.class) + .build()) .build(); EntityProjection actual = maker.parsePath(path); @@ -250,7 +258,12 @@ public void testRelationshipWithSingleInclude() { .attribute(Attribute.builder().name("name").type(String.class).build()) .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().name("vacationHomes").type(Set.class).build()) + .attribute(Attribute.builder().name("stuff").type(Map.class).build()) .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .relationship("products", EntityProjection.builder() + .type(Book.class) + .build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) @@ -284,15 +297,21 @@ public void testRootCollectionWithSingleInclude() { .attribute(Attribute.builder().name("language").type(String.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .attribute(Attribute.builder().name("name").type(String.class).build()) .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().name("vacationHomes").type(Set.class).build()) + .attribute(Attribute.builder().name("stuff").type(Map.class).build()) .attribute(Attribute.builder().name("awards").type(Collection.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) + .relationship("products", EntityProjection.builder() + .type(Book.class) + .build()) .build()) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) @@ -326,15 +345,21 @@ public void testRootEntityWithSingleInclude() { .attribute(Attribute.builder().name("language").type(String.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .attribute(Attribute.builder().name("name").type(String.class).build()) .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().name("vacationHomes").type(Set.class).build()) + .attribute(Attribute.builder().name("stuff").type(Map.class).build()) .attribute(Attribute.builder().name("awards").type(Collection.class).build()) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) + .relationship("products", EntityProjection.builder() + .type(Book.class) + .build()) .build()) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) @@ -363,9 +388,14 @@ public void testRootCollectionWithNestedInclude() throws Exception { EntityProjection expected = EntityProjection.builder() .type(Author.class) .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().name("vacationHomes").type(Set.class).build()) + .attribute(Attribute.builder().name("stuff").type(Map.class).build()) .attribute(Attribute.builder().name("name").type(String.class).build()) .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .relationship("products", EntityProjection.builder() + .type(Book.class) + .build()) .relationship("books", EntityProjection.builder() .type(Book.class) .attribute(Attribute.builder().name("title").type(String.class).build()) @@ -374,6 +404,7 @@ public void testRootCollectionWithNestedInclude() throws Exception { .attribute(Attribute.builder().name("language").type(String.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .relationship("editor", EntityProjection.builder() .type(Editor.class) .attribute(Attribute.builder().name("firstName").type(String.class).build()) @@ -422,7 +453,12 @@ public void testRootEntityWithNestedInclude() { .attribute(Attribute.builder().name("name").type(String.class).build()) .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().name("vacationHomes").type(Set.class).build()) + .attribute(Attribute.builder().name("stuff").type(Map.class).build()) .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .relationship("products", EntityProjection.builder() + .type(Book.class) + .build()) .relationship("books", EntityProjection.builder() .type(Book.class) .attribute(Attribute.builder().name("title").type(String.class).build()) @@ -431,6 +467,7 @@ public void testRootEntityWithNestedInclude() { .attribute(Attribute.builder().name("language").type(String.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) .attribute(Attribute.builder().name("name").type(String.class).build()) @@ -488,6 +525,7 @@ public void testNestedEntityWithSingleInclude() { .attribute(Attribute.builder().name("language").type(String.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .build()) @@ -535,6 +573,7 @@ public void testNestedCollectionWithSingleInclude() { .attribute(Attribute.builder().name("language").type(String.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .relationship("authors", EntityProjection.builder() .type(Author.class) .build()) @@ -578,7 +617,12 @@ public void testRootEntityWithNestedIncludeAndSparseFields() { .attribute(Attribute.builder().name("name").type(String.class).build()) .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().name("vacationHomes").type(Set.class).build()) + .attribute(Attribute.builder().name("stuff").type(Map.class).build()) .attribute(Attribute.builder().name("awards").type(Collection.class).build()) + .relationship("products", EntityProjection.builder() + .type(Book.class) + .build()) .relationship("books", EntityProjection.builder() .type(Book.class) .attribute(Attribute.builder().name("title").type(String.class).build()) @@ -619,6 +663,7 @@ public void testRootCollectionWithGlobalFilter() { .attribute(Attribute.builder().name("awards").type(Collection.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .filterExpression(expression) .relationship("publisher", EntityProjection.builder() .type(Publisher.class) @@ -701,11 +746,16 @@ public void testRelationshipsAndIncludeWithFilterAndSort() { .attribute(Attribute.builder().name("name").type(String.class).build()) .attribute(Attribute.builder().name("type").type(Author.AuthorType.class).build()) .attribute(Attribute.builder().name("homeAddress").type(Address.class).build()) + .attribute(Attribute.builder().name("vacationHomes").type(Set.class).build()) + .attribute(Attribute.builder().name("stuff").type(Map.class).build()) .attribute(Attribute.builder().name("awards").type(Collection.class).build()) .filterExpression(new InPredicate(new Path(Author.class, dictionary, "name"), "Foo")) .relationship("books", EntityProjection.builder() .type(Book.class) .build()) + .relationship("products", EntityProjection.builder() + .type(Book.class) + .build()) .type(Author.class) .build()) .relationship("editor", EntityProjection.builder() @@ -739,6 +789,7 @@ public void testRootCollectionWithTypedFilter() { .attribute(Attribute.builder().name("language").type(String.class).build()) .attribute(Attribute.builder().name("publishDate").type(long.class).build()) .attribute(Attribute.builder().name("authorTypes").type(Collection.class).build()) + .attribute(Attribute.builder().name("price").type(Price.class).build()) .filterExpression(expression) .relationship("authors", EntityProjection.builder() .type(Author.class) diff --git a/elide-core/src/test/java/com/yahoo/elide/jsonapi/JsonApiTest.java b/elide-core/src/test/java/com/yahoo/elide/jsonapi/JsonApiTest.java index 914665e64e..dd620a6ea0 100644 --- a/elide-core/src/test/java/com/yahoo/elide/jsonapi/JsonApiTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/jsonapi/JsonApiTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; @@ -23,6 +24,7 @@ import com.yahoo.elide.jsonapi.models.Resource; import com.yahoo.elide.jsonapi.models.ResourceIdentifier; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonMappingException; import com.google.common.collect.Lists; import com.google.common.collect.Sets; import example.Child; @@ -127,6 +129,27 @@ public void writeSingle() throws JsonProcessingException { checkEquality(jsonApiDocument); } + @Test + public void writeSingleWithMeta() throws JsonProcessingException { + Child child = new Child(); + child.setId(2); + child.setMetadataField("foo", "bar"); + + RequestScope userScope = new TestRequestScope(BASE_URL, tx, user, dictionary); + + JsonApiDocument jsonApiDocument = new JsonApiDocument(); + jsonApiDocument.setData(new Data<>(new PersistentResource<>(child, userScope.getUUIDFor(child), userScope).toResource())); + + String expected = "{\"data\":{\"type\":\"child\",\"id\":\"2\",\"" + + "links\":{\"self\":\"http://localhost:8080/json/child/2\"},\"meta\":{\"foo\":\"bar\"}}}"; + + Data data = jsonApiDocument.getData(); + String doc = mapper.writeJsonApiDocument(jsonApiDocument); + assertEquals(data, jsonApiDocument.getData()); + + assertEquals(expected, doc); + } + @Test public void writeSingleIncluded() throws JsonProcessingException { Parent parent = new Parent(); @@ -325,6 +348,20 @@ public void writeNullObject() throws JsonProcessingException { checkEquality(jsonApiDocument); } + @Test + public void testMissingTypeInResource() { + String doc = "{ \"data\": { \"id\": \"22\", \"attributes\": { \"title\": \"works fine\" } } }"; + + assertThrows(JsonMappingException.class, () -> mapper.readJsonApiDocument(doc)); + } + + @Test + public void testMissingTypeInResourceList() { + String doc = "{ \"data\": [{ \"id\": \"22\", \"attributes\": { \"title\": \"works fine\" } } ]}"; + + assertThrows(JsonMappingException.class, () -> mapper.readJsonApiDocument(doc)); + } + @Test public void readSingle() throws IOException { String doc = "{\"data\":{\"type\":\"parent\",\"id\":\"123\",\"attributes\":{\"firstName\":\"bob\"},\"relationships\":{\"children\":{\"data\":{\"type\":\"child\",\"id\":\"2\"}}}}}"; @@ -452,7 +489,7 @@ public void readListWithMeta() throws IOException { } @Test - public void compareNullAndEmpty() throws JsonProcessingException { + public void compareNullAndEmpty() { Data empty = new Data<>((Resource) null); JsonApiDocument jsonApiEmpty = new JsonApiDocument(); @@ -466,7 +503,7 @@ public void compareNullAndEmpty() throws JsonProcessingException { } @Test - public void compareOrder() throws JsonProcessingException { + public void compareOrder() { Parent parent1 = new Parent(); parent1.setId(123L); Parent parent2 = new Parent(); diff --git a/elide-core/src/test/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessorTest.java b/elide-core/src/test/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessorTest.java index e9c0d78df8..4bc231ce09 100644 --- a/elide-core/src/test/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessorTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/jsonapi/document/processors/IncludedProcessorTest.java @@ -25,11 +25,7 @@ import org.junit.jupiter.api.Test; import org.mockito.Answers; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.util.*; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; @@ -109,7 +105,7 @@ public void testExecuteSingleRelation() throws Exception { MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.put(INCLUDE, Collections.singletonList("children")); testScope.setQueryParams(queryParams); - includedProcessor.execute(jsonApiDocument, parentRecord1, queryParams); + includedProcessor.execute(jsonApiDocument, testScope, parentRecord1, queryParams); List expectedIncluded = Collections.singletonList(childRecord1.toResource()); List actualIncluded = jsonApiDocument.getIncluded(); @@ -122,14 +118,14 @@ public void testExecuteSingleRelation() throws Exception { public void testExecuteSingleRelationOnCollection() throws Exception { JsonApiDocument jsonApiDocument = new JsonApiDocument(); - Set parents = new HashSet<>(); + LinkedHashSet parents = new LinkedHashSet<>(); parents.add(parentRecord1); parents.add(parentRecord2); MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.put(INCLUDE, Collections.singletonList("children")); testScope.setQueryParams(queryParams); - includedProcessor.execute(jsonApiDocument, parents, queryParams); + includedProcessor.execute(jsonApiDocument, testScope, parents, queryParams); List expectedIncluded = Arrays.asList(childRecord1.toResource(), childRecord2.toResource()); List actualIncluded = jsonApiDocument.getIncluded(); @@ -146,7 +142,7 @@ public void testExecuteSingleNestedRelation() throws Exception { MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.put(INCLUDE, Collections.singletonList("children.friends")); testScope.setQueryParams(queryParams); - includedProcessor.execute(jsonApiDocument, parentRecord1, queryParams); + includedProcessor.execute(jsonApiDocument, testScope, parentRecord1, queryParams); List expectedIncluded = Arrays.asList(childRecord1.toResource(), childRecord2.toResource()); @@ -163,7 +159,7 @@ public void testExecuteMultipleRelations() throws Exception { MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.put(INCLUDE, Arrays.asList("children", "spouses")); testScope.setQueryParams(queryParams); - includedProcessor.execute(jsonApiDocument, parentRecord1, queryParams); + includedProcessor.execute(jsonApiDocument, testScope, parentRecord1, queryParams); List expectedIncluded = Arrays.asList(childRecord1.toResource(), parentRecord2.toResource()); @@ -180,7 +176,7 @@ public void testExecuteMultipleNestedRelations() throws Exception { MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.put(INCLUDE, Collections.singletonList("children.friends")); testScope.setQueryParams(queryParams); - includedProcessor.execute(jsonApiDocument, parentRecord3, queryParams); + includedProcessor.execute(jsonApiDocument, testScope, parentRecord3, queryParams); Set expectedIncluded = Sets.newHashSet( @@ -202,7 +198,7 @@ public void testIncludeForbiddenRelationship() { MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.put(INCLUDE, Collections.singletonList("relation1")); testScope.setQueryParams(queryParams); - includedProcessor.execute(jsonApiDocument, funWithPermissionsRecord, queryParams); + includedProcessor.execute(jsonApiDocument, testScope, funWithPermissionsRecord, queryParams); assertNull(jsonApiDocument.getIncluded(), "Included Processor included forbidden relationship"); @@ -211,7 +207,7 @@ public void testIncludeForbiddenRelationship() { @Test public void testNoQueryParams() throws Exception { JsonApiDocument jsonApiDocument = new JsonApiDocument(); - includedProcessor.execute(jsonApiDocument, parentRecord1, null); + includedProcessor.execute(jsonApiDocument, testScope, parentRecord1, null); assertNull(jsonApiDocument.getIncluded(), "Included Processor adds no resources when not given query params"); @@ -225,7 +221,7 @@ public void testNoQueryIncludeParams() throws Exception { MultivaluedMap queryParams = new MultivaluedHashMap<>(); queryParams.put("unused", Collections.emptyList()); testScope.setQueryParams(queryParams); - includedProcessor.execute(jsonApiDocument, parentRecord1, queryParams); + includedProcessor.execute(jsonApiDocument, testScope, parentRecord1, queryParams); assertNull(jsonApiDocument.getIncluded(), "Included Processor adds no resources when not given query params"); diff --git a/elide-core/src/test/java/com/yahoo/elide/jsonapi/document/processors/PopulateMetaProcessorTest.java b/elide-core/src/test/java/com/yahoo/elide/jsonapi/document/processors/PopulateMetaProcessorTest.java new file mode 100644 index 0000000000..1647699b76 --- /dev/null +++ b/elide-core/src/test/java/com/yahoo/elide/jsonapi/document/processors/PopulateMetaProcessorTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.jsonapi.document.processors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.jsonapi.models.Data; +import com.yahoo.elide.jsonapi.models.JsonApiDocument; +import com.yahoo.elide.jsonapi.models.Meta; +import com.yahoo.elide.jsonapi.models.Resource; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public class PopulateMetaProcessorTest { + + @Test + public void testRequestScopeWithMeta() { + RequestScope scope = mock(RequestScope.class); + when(scope.getMetadataFields()).thenReturn(Set.of("foo")); + when(scope.getMetadataField(eq("foo"))).thenReturn(Optional.of("bar")); + + PersistentResource persistentResource = mock(PersistentResource.class); + when(persistentResource.getRequestScope()).thenReturn(scope); + + PopulateMetaProcessor metaProcessor = new PopulateMetaProcessor(); + JsonApiDocument doc = new JsonApiDocument(); + doc.setData(new Data(List.of())); + metaProcessor.execute(doc, scope, new LinkedHashSet<>(Set.of(persistentResource)), null); + + Meta meta = doc.getMeta(); + assertNotNull(meta); + assertEquals(1, meta.getMetaMap().size()); + assertEquals("bar", meta.getMetaMap().get("foo")); + } +} diff --git a/elide-core/src/test/java/com/yahoo/elide/parsers/JsonApiParserTest.java b/elide-core/src/test/java/com/yahoo/elide/parsers/JsonApiParserTest.java index cdf66d5a9e..88a73da2d1 100644 --- a/elide-core/src/test/java/com/yahoo/elide/parsers/JsonApiParserTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/parsers/JsonApiParserTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/test/java/com/yahoo/elide/parsers/expression/PermissionExpressionVisitorTest.java b/elide-core/src/test/java/com/yahoo/elide/parsers/expression/PermissionExpressionVisitorTest.java index 5d1d421955..2184363479 100644 --- a/elide-core/src/test/java/com/yahoo/elide/parsers/expression/PermissionExpressionVisitorTest.java +++ b/elide-core/src/test/java/com/yahoo/elide/parsers/expression/PermissionExpressionVisitorTest.java @@ -158,7 +158,7 @@ public ExpressionResult evaluate(EvaluationMode ignored) { @Override public T accept(ExpressionVisitor visitor) { - return visitor.visitExpression(this); + return null; } } } diff --git a/elide-core/src/test/java/example/Author.java b/elide-core/src/test/java/example/Author.java index 4302945865..4a7c2b6cae 100644 --- a/elide-core/src/test/java/example/Author.java +++ b/elide-core/src/test/java/example/Author.java @@ -13,6 +13,8 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Map; +import java.util.Set; import java.util.UUID; import javax.persistence.ElementCollection; import javax.persistence.Entity; @@ -64,12 +66,22 @@ public boolean equals(Object obj) { @Getter @Setter private Collection books = new ArrayList<>(); + @ManyToMany(targetEntity = Book.class, mappedBy = "authors") + @Getter @Setter + private Collection products = new ArrayList<>(); + @Getter @Setter private AuthorType type; @Getter @Setter private Address homeAddress; + @Getter @Setter + private Set
vacationHomes; + + @Getter @Setter + private Map stuff; + @ElementCollection @Getter @Setter private Collection awards; diff --git a/elide-core/src/test/java/example/Book.java b/elide-core/src/test/java/example/Book.java index 36a1889de9..53777d4f6b 100644 --- a/elide-core/src/test/java/example/Book.java +++ b/elide-core/src/test/java/example/Book.java @@ -49,7 +49,7 @@ logExpressions = {"${book.title}"}) @AllArgsConstructor @NoArgsConstructor -public class Book { +public class Book implements Product { private long id; private String title; private String genre; @@ -58,6 +58,7 @@ public class Book { private Collection authors = new ArrayList<>(); private Publisher publisher = null; private Collection awards = new ArrayList<>(); + private Price price; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) public long getId() { @@ -134,6 +135,14 @@ public void setPublisher(Publisher publisher) { this.publisher = publisher; } + public Price getPrice() { + return price; + } + + public void setPrice(Price price) { + this.price = price; + } + @Transient @ComputedRelationship @OneToOne diff --git a/elide-core/src/test/java/example/Child.java b/elide-core/src/test/java/example/Child.java index 3e51b6f3d2..3c2f869d8a 100644 --- a/elide-core/src/test/java/example/Child.java +++ b/elide-core/src/test/java/example/Child.java @@ -5,25 +5,19 @@ */ package example; -import com.yahoo.elide.annotation.Audit; -import com.yahoo.elide.annotation.CreatePermission; -import com.yahoo.elide.annotation.Include; -import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.*; import com.yahoo.elide.core.security.ChangeSpec; import com.yahoo.elide.core.security.RequestScope; import com.yahoo.elide.core.security.checks.OperationCheck; +import com.yahoo.elide.jsonapi.document.processors.WithMetadata; + import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; import java.util.Set; -import javax.persistence.CascadeType; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.GenerationType; -import javax.persistence.Id; -import javax.persistence.ManyToMany; -import javax.persistence.OneToOne; +import javax.persistence.*; @Entity(name = "childEntity") @CreatePermission(expression = "initCheck") @@ -37,12 +31,13 @@ operation = 0, logStatement = "CREATE Child {0} Parent {1}", logExpressions = {"${child.id}", "${parent.id}"}) -public class Child { +public class Child implements WithMetadata { @JsonIgnore private long id; private Set parents; + private Map metadata = new HashMap<>(); private String name; @@ -109,6 +104,27 @@ public void setReadNoAccess(Child noReadAccess) { this.noReadAccess = noReadAccess; } + @Exclude + @Transient + @Override + public void setMetadataField(String property, Object value) { + metadata.put(property, value); + } + + @Exclude + @Transient + @Override + public Optional getMetadataField(String property) { + return Optional.ofNullable(metadata.getOrDefault(property, null)); + } + + @Exclude + @Transient + @Override + public Set getMetadataFields() { + return metadata.keySet(); + } + static public class InitCheck extends OperationCheck { @Override public boolean ok(Child child, RequestScope requestScope, Optional changeSpec) { diff --git a/elide-core/src/test/java/example/Price.java b/elide-core/src/test/java/example/Price.java new file mode 100644 index 0000000000..1d42001c6c --- /dev/null +++ b/elide-core/src/test/java/example/Price.java @@ -0,0 +1,25 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.Currency; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Price { + private BigDecimal units; + private Currency currency; + + //Ignored by Elide.... + Book book; +} diff --git a/elide-core/src/test/java/example/Product.java b/elide-core/src/test/java/example/Product.java new file mode 100644 index 0000000000..04aa48d4eb --- /dev/null +++ b/elide-core/src/test/java/example/Product.java @@ -0,0 +1,10 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example; + +public interface Product { +} diff --git a/elide-core/src/test/java/example/UpdateAndCreate.java b/elide-core/src/test/java/example/UpdateAndCreate.java index ab2c78b5be..9a8f06cd02 100644 --- a/elide-core/src/test/java/example/UpdateAndCreate.java +++ b/elide-core/src/test/java/example/UpdateAndCreate.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-core/src/test/java/example/UserIdChecks.java b/elide-core/src/test/java/example/UserIdChecks.java index 32e21f5b71..6de3ef1faa 100644 --- a/elide-core/src/test/java/example/UserIdChecks.java +++ b/elide-core/src/test/java/example/UserIdChecks.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-aggregation/pom.xml b/elide-datastore/elide-datastore-aggregation/pom.xml index 3f2b783fbd..e93d394a4c 100644 --- a/elide-datastore/elide-datastore-aggregation/pom.xml +++ b/elide-datastore/elide-datastore-aggregation/pom.xml @@ -14,7 +14,7 @@ com.yahoo.elide elide-datastore-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -40,38 +40,38 @@ - 5.0.0 + 5.0.1 com.yahoo.elide elide-core - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-datastore-jpa - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-graphql - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-datastore-multiplex - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-model-config - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -90,7 +90,7 @@ org.apache.calcite calcite-core - 1.27.0 + 1.32.0 @@ -110,7 +110,7 @@ org.apache.httpcomponents httpcore - 4.4.14 + 4.4.15 runtime @@ -126,7 +126,7 @@ com.github.ben-manes.caffeine caffeine - 3.0.3 + 3.1.1 @@ -135,7 +135,20 @@ ${hikari.version} + + + redis.clients + jedis + ${jedis.version} + + + + com.github.codemonstur + embedded-redis + ${embedded-redis.version} + test + org.junit.jupiter @@ -161,14 +174,6 @@ test - - - com.h2database - h2 - 1.4.197 - test - - org.mockito @@ -178,7 +183,7 @@ org.mockito mockito-junit-jupiter - 3.12.1 + 4.8.1 test @@ -186,7 +191,7 @@ com.yahoo.elide elide-integration-tests - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT test test-jar diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/AggregationDataStore.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/AggregationDataStore.java index 35cfd954a7..ca88f8c2c2 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/AggregationDataStore.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/AggregationDataStore.java @@ -16,9 +16,12 @@ import com.yahoo.elide.core.security.checks.FilterExpressionCheck; import com.yahoo.elide.core.security.checks.UserCheck; import com.yahoo.elide.core.security.executors.AggregationStorePermissionExecutor; +import com.yahoo.elide.core.type.AccessibleObject; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.datastores.aggregation.annotation.ColumnMeta; import com.yahoo.elide.datastores.aggregation.annotation.Join; +import com.yahoo.elide.datastores.aggregation.annotation.TableMeta; import com.yahoo.elide.datastores.aggregation.cache.Cache; import com.yahoo.elide.datastores.aggregation.core.QueryLogger; import com.yahoo.elide.datastores.aggregation.metadata.enums.ValueType; @@ -36,7 +39,6 @@ import java.lang.annotation.Annotation; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Set; @@ -54,6 +56,19 @@ public class AggregationDataStore implements DataStore { private final Set> dynamicCompiledClasses; private final QueryLogger queryLogger; + public static final Predicate IS_FIELD_HIDDEN = (field -> { + ColumnMeta meta = field.getAnnotation(ColumnMeta.class); + Join join = field.getAnnotation(Join.class); + + return (join != null || (meta != null && meta.isHidden())); + }); + + public static final Predicate> IS_TYPE_HIDDEN = (type -> { + TableMeta meta = type.getAnnotation(TableMeta.class); + + return (meta != null && meta.isHidden()); + }); + private final Function aggPermissionExecutor = AggregationStorePermissionExecutor::new; @@ -71,15 +86,19 @@ public class AggregationDataStore implements DataStore { public void populateEntityDictionary(EntityDictionary dictionary) { if (dynamicCompiledClasses != null && dynamicCompiledClasses.size() != 0) { - dynamicCompiledClasses.forEach(dynamicLoadedClass -> { - dictionary.bindEntity(dynamicLoadedClass, Collections.singleton(Join.class)); + dynamicCompiledClasses.stream() + .filter((type) -> ! IS_TYPE_HIDDEN.test(type)) + .forEach(dynamicLoadedClass -> { + dictionary.bindEntity(dynamicLoadedClass, IS_FIELD_HIDDEN); validateModelExpressionChecks(dictionary, dynamicLoadedClass); dictionary.bindPermissionExecutor(dynamicLoadedClass, aggPermissionExecutor); }); } - dictionary.getScanner().getAnnotatedClasses(AGGREGATION_STORE_CLASSES).forEach(cls -> { - dictionary.bindEntity(cls, Collections.singleton(Join.class)); + dictionary.getScanner().getAnnotatedClasses(AGGREGATION_STORE_CLASSES).stream() + .filter((type) -> ! IS_TYPE_HIDDEN.test(ClassType.of(type))) + .forEach(cls -> { + dictionary.bindEntity(cls, IS_FIELD_HIDDEN); validateModelExpressionChecks(dictionary, ClassType.of(cls)); dictionary.bindPermissionExecutor(cls, aggPermissionExecutor); } @@ -87,7 +106,7 @@ public void populateEntityDictionary(EntityDictionary dictionary) { for (Table table : queryEngine.getMetaDataStore().getMetaData(ClassType.of(Table.class))) { /* Add 'grain' argument to each TimeDimensionColumn */ - for (TimeDimension timeDim : table.getTimeDimensions()) { + for (TimeDimension timeDim : table.getAllTimeDimensions()) { dictionary.addArgumentToAttribute( dictionary.getEntityClass(table.getName(), table.getVersion()), timeDim.getName(), @@ -95,7 +114,7 @@ public void populateEntityDictionary(EntityDictionary dictionary) { } /* Add argument to each Column */ - for (Column col : table.getColumns()) { + for (Column col : table.getAllColumns()) { for (ArgumentDefinition arg : col.getArgumentDefinitions()) { dictionary.addArgumentToAttribute( dictionary.getEntityClass(table.getName(), table.getVersion()), @@ -137,7 +156,7 @@ private void validateModelExpressionChecks(EntityDictionary dictionary, Type + "Operation Checks Not allowed. given - %s"); } - dictionary.getAllFields(clz).stream() + dictionary.getAllExposedFields(clz).stream() .map(field -> dictionary.getPermissionsForField(clz, field, ReadPermission.class)) .filter(Objects::nonNull) .forEach(tree -> diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/AggregationDataStoreTransaction.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/AggregationDataStoreTransaction.java index 4fcc4bb38e..a954d70ece 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/AggregationDataStoreTransaction.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/AggregationDataStoreTransaction.java @@ -6,6 +6,8 @@ package com.yahoo.elide.datastores.aggregation; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.BadRequestException; @@ -28,6 +30,7 @@ import com.yahoo.elide.datastores.aggregation.query.Query; import com.yahoo.elide.datastores.aggregation.query.QueryResult; import com.google.common.annotations.VisibleForTesting; +import org.apache.commons.compress.utils.Lists; import lombok.ToString; import java.io.IOException; @@ -82,7 +85,7 @@ public void createObject(T entity, RequestScope scope) { } @Override - public Iterable loadObjects(EntityProjection entityProjection, RequestScope scope) { + public DataStoreIterable loadObjects(EntityProjection entityProjection, RequestScope scope) { QueryResult result = null; QueryResponse response = null; String cacheKey = null; @@ -114,14 +117,23 @@ public Iterable loadObjects(EntityProjection entityProjection, RequestSco if (result == null) { result = queryEngine.executeQuery(query, queryEngineTransaction); if (cacheKey != null) { - cache.put(cacheKey, result); + + //The query result needs to be streamed into an in memory list before caching. + //TODO - add a cap to how many records can be streamed back. If this is exceeded, abort caching + //and return the results. + QueryResult cacheableResult = QueryResult.builder() + .data(Lists.newArrayList(result.getData().iterator())) + .pageTotals(result.getPageTotals()) + .build(); + cache.put(cacheKey, cacheableResult); + result = cacheableResult; } } if (entityProjection.getPagination() != null && entityProjection.getPagination().returnPageTotals()) { entityProjection.getPagination().setPageTotals(result.getPageTotals()); } response = new QueryResponse(HttpStatus.SC_OK, result.getData(), null); - return result.getData(); + return new DataStoreIterableBuilder(result.getData()).build(); } catch (HttpStatusException e) { response = new QueryResponse(e.getStatus(), null, e.getMessage()); throw e; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/DefaultQueryValidator.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/DefaultQueryValidator.java index 805f34344b..4a320d3c21 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/DefaultQueryValidator.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/DefaultQueryValidator.java @@ -9,6 +9,7 @@ import com.yahoo.elide.core.Path; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.InvalidOperationException; +import com.yahoo.elide.core.filter.Operator; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.filter.expression.PredicateExtractionVisitor; import com.yahoo.elide.core.filter.predicates.FilterPredicate; @@ -22,18 +23,25 @@ import com.yahoo.elide.datastores.aggregation.query.Query; import com.yahoo.elide.datastores.aggregation.queryengines.sql.metadata.SQLTable; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Class that checks whether a constructed {@link Query} object can be executed. * Checks include validate sorting, having clause and make sure there is at least 1 metric queried. */ public class DefaultQueryValidator implements QueryValidator { + private static final Set REGEX_OPERATORS = Stream.of(Operator.INFIX, Operator.INFIX_CASE_INSENSITIVE, + Operator.POSTFIX, Operator.POSTFIX_CASE_INSENSITIVE, Operator.PREFIX, Operator.PREFIX_CASE_INSENSITIVE) + .collect(Collectors.toCollection(HashSet::new)); + protected EntityDictionary dictionary; public DefaultQueryValidator(EntityDictionary dictionary) { @@ -58,10 +66,21 @@ public void validateHavingClause(Query query) { validatePredicate(query, predicate); extractFilterProjections(query, havingClause).stream().forEach(projection -> { - if (query.getColumnProjection(projection.getAlias(), projection.getArguments()) == null) { + + Predicate filterByNameAndArgs = + (column) -> (column.getAlias().equals(projection.getAlias()) + || column.getName().equals(projection.getName())) + && column.getArguments().equals(projection.getArguments()); + + //Query by (alias or name) and arguments. The filter may or may not be using the alias. + if (query.getColumnProjection(filterByNameAndArgs) == null) { + + Predicate filterByName = + (column) -> (column.getAlias().equals(projection.getAlias()) + || column.getName().equals(projection.getName())); //The column wasn't projected at all. - if (query.getColumnProjection(projection.getAlias()) == null) { + if (query.getColumnProjection(filterByName) == null) { throw new InvalidOperationException(String.format( "Post aggregation filtering on '%s' requires the field to be projected in the response", projection.getAlias())); @@ -120,6 +139,10 @@ protected void validatePredicate(Query query, FilterPredicate predicate) { return; } + if (REGEX_OPERATORS.contains(predicate.getOperator())) { + return; + } + predicate.getValues().forEach(value -> { if (! column.getValues().contains(value)) { throw new InvalidOperationException(String.format( diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/EntityProjectionTranslator.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/EntityProjectionTranslator.java index 5e9c31fec5..0afdd6f2aa 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/EntityProjectionTranslator.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/EntityProjectionTranslator.java @@ -7,13 +7,10 @@ import static com.yahoo.elide.core.request.Argument.getArgumentMapFromArgumentSet; import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.InvalidOperationException; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.filter.expression.PredicateExtractionVisitor; -import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.request.EntityProjection; -import com.yahoo.elide.core.request.Relationship; import com.yahoo.elide.datastores.aggregation.filter.visitor.FilterConstraints; import com.yahoo.elide.datastores.aggregation.filter.visitor.SplitFilterExpressionVisitor; import com.yahoo.elide.datastores.aggregation.metadata.models.Dimension; @@ -47,7 +44,6 @@ public class EntityProjectionTranslator { private List metrics; private FilterExpression whereFilter; private FilterExpression havingFilter; - private EntityDictionary dictionary; private Boolean bypassCache; private RequestScope scope; @@ -57,7 +53,6 @@ public EntityProjectionTranslator(QueryEngine engine, Table table, this.engine = engine; this.queriedTable = table; this.entityProjection = entityProjection; - this.dictionary = scope.getDictionary(); this.bypassCache = bypassCache; this.scope = scope; dimensionProjections = resolveNonTimeDimensions(); @@ -124,7 +119,7 @@ private void addHavingMetrics() { if (queriedTable.getMetric(fieldName) != null) { //If the query doesn't contain this metric. - if (metrics.stream().noneMatch((metric -> metric.getAlias().equals(fieldName)))) { + if (metrics.stream().noneMatch((metric -> metric.getName().equals(fieldName)))) { //Construct a new projection and add it to the query. MetricProjection havingMetric = engine.constructMetricProjection( @@ -202,32 +197,4 @@ private List resolveMetrics() { getArgumentMapFromArgumentSet(attribute.getArguments()))) .collect(Collectors.toList()); } - - /** - * Gets relationship names from {@link EntityProjection}. - * @return relationships list of {@link Relationship} names - */ - private Set getRelationships() { - return entityProjection.getRelationships().stream() - .map(Relationship::getName).collect(Collectors.toCollection(LinkedHashSet::new)); - } - - /** - * Gets attribute names from {@link EntityProjection}. - * @return relationships list of {@link Attribute} names - */ - private Set getAttributes() { - return entityProjection.getAttributes().stream() - .map(Attribute::getName).collect(Collectors.toCollection(LinkedHashSet::new)); - } - - /** - * Helper method to get all field names from the {@link EntityProjection}. - * @return allFields set of all field names - */ - private Set getAllFields() { - Set allFields = getAttributes(); - allFields.addAll(getRelationships()); - return allFields; - } } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/QueryEngine.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/QueryEngine.java index 3376eb1b22..babb586be5 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/QueryEngine.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/QueryEngine.java @@ -176,7 +176,7 @@ protected void populateMetaData(MetaDataStore metaDataStore) { )); }); - table.getColumns().forEach(column -> { + table.getAllColumns().forEach(column -> { //Populate column sources. column.setTableSource(TableSource.fromDefinition( diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/annotation/ColumnMeta.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/annotation/ColumnMeta.java index 99dd46ac3f..1916685065 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/annotation/ColumnMeta.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/annotation/ColumnMeta.java @@ -25,6 +25,12 @@ String [] tags() default {}; String [] values() default {}; + /** + * Controls whether this column is exposed through the Metadata store. + * @return true or false. + */ + boolean isHidden() default false; + /** * Whether or not querying this column requires a client provided filter. * @return The required filter template. diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/annotation/TableMeta.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/annotation/TableMeta.java index bd024a3bf7..1999cb8edd 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/annotation/TableMeta.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/annotation/TableMeta.java @@ -43,6 +43,12 @@ */ boolean isFact() default true; + /** + * Controls whether this table is exposed through the Metadata store. + * @return true or false. + */ + boolean isHidden() default false; + /** * Indicates the size of the table. * @return size diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/cache/RedisCache.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/cache/RedisCache.java new file mode 100644 index 0000000000..d5cbeef214 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/cache/RedisCache.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.aggregation.cache; + +import com.yahoo.elide.datastores.aggregation.query.QueryResult; + +import org.springframework.util.SerializationUtils; + +import lombok.Setter; +import redis.clients.jedis.UnifiedJedis; + +/** + * A Redis cache. + */ +public class RedisCache implements Cache { + @Setter private UnifiedJedis jedis; + @Setter private long defaultExprirationMinutes; + + /** + * Constructor. + * @param jedis Jedis Connection Pool to Redis clusteer. + * @param defaultExprirationMinutes Expiration Time for results on Redis. + */ + public RedisCache(UnifiedJedis jedis, long defaultExprirationMinutes) { + this.jedis = jedis; + this.defaultExprirationMinutes = defaultExprirationMinutes; + } + + @Override + public QueryResult get(Object key) { + return (QueryResult) SerializationUtils.deserialize(jedis.get(SerializationUtils.serialize(key))); + } + + @Override + public void put(Object key, QueryResult result) { + byte[] keyBytes = SerializationUtils.serialize(key); + jedis.set(keyBytes, SerializationUtils.serialize(result)); + jedis.expire(keyBytes, defaultExprirationMinutes * 60); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/DynamicModelInstance.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/DynamicModelInstance.java index 2ece1f3880..cb39379ae7 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/DynamicModelInstance.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/DynamicModelInstance.java @@ -13,6 +13,8 @@ * Base model instance returned by AggregationStore for dynamic types. */ public class DynamicModelInstance extends ParameterizedModel implements Dynamic { + private static final long serialVersionUID = -374837200186480683L; + protected TableType tableType; public DynamicModelInstance(TableType type) { diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/FieldType.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/FieldType.java index 769889fc28..818c90b37e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/FieldType.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/FieldType.java @@ -6,12 +6,14 @@ package com.yahoo.elide.datastores.aggregation.dynamic; import static java.lang.reflect.Modifier.PUBLIC; + import com.yahoo.elide.core.exceptions.InvalidParameterizedAttributeException; import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.type.Field; import com.yahoo.elide.core.type.ParameterizedModel; import com.yahoo.elide.core.type.Type; + import java.lang.annotation.Annotation; import java.lang.reflect.Array; import java.util.Map; @@ -21,7 +23,8 @@ * A dynamic Elide model field that wraps a deserialized HJSON measure or dimension. */ public class FieldType implements Field { - private Map, Annotation> annotations; + private static final long serialVersionUID = -1358950447581934754L; + private transient Map, Annotation> annotations; private String name; private Type type; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/NamespacePackage.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/NamespacePackage.java index 1583072630..3e9388b145 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/NamespacePackage.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/NamespacePackage.java @@ -12,6 +12,8 @@ import com.yahoo.elide.core.type.Package; import com.yahoo.elide.modelconfig.model.NamespaceConfig; +import lombok.EqualsAndHashCode; + import java.lang.annotation.Annotation; import java.util.HashMap; import java.util.Map; @@ -19,7 +21,9 @@ /** * A dynamic Elide model that wraps a deserialized HJSON Namespace. */ +@EqualsAndHashCode public class NamespacePackage implements Package { + private static final long serialVersionUID = -7173317858416763972L; public static final String EMPTY = ""; public static final String DEFAULT = "default"; @@ -27,7 +31,7 @@ public class NamespacePackage implements Package { new NamespacePackage(EMPTY, "Default Namespace", DEFAULT, NO_VERSION); protected NamespaceConfig namespace; - private Map, Annotation> annotations; + private transient Map, Annotation> annotations; public NamespacePackage(NamespaceConfig namespace) { if (namespace.getName().equals(DEFAULT)) { diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/TableType.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/TableType.java index 5a054b14dd..f3cd0e6697 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/TableType.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/dynamic/TableType.java @@ -12,8 +12,15 @@ import static com.yahoo.elide.datastores.aggregation.dynamic.NamespacePackage.DEFAULT; import static com.yahoo.elide.datastores.aggregation.dynamic.NamespacePackage.DEFAULT_NAMESPACE; import static com.yahoo.elide.datastores.aggregation.timegrains.Time.TIME_TYPE; +import static com.yahoo.elide.modelconfig.model.Type.BOOLEAN; +import static com.yahoo.elide.modelconfig.model.Type.COORDINATE; +import static com.yahoo.elide.modelconfig.model.Type.DECIMAL; +import static com.yahoo.elide.modelconfig.model.Type.ENUM_ORDINAL; +import static com.yahoo.elide.modelconfig.model.Type.ENUM_TEXT; +import static com.yahoo.elide.modelconfig.model.Type.INTEGER; +import static com.yahoo.elide.modelconfig.model.Type.MONEY; +import static com.yahoo.elide.modelconfig.model.Type.TEXT; import static com.yahoo.elide.modelconfig.model.Type.TIME; -import com.yahoo.elide.annotation.Exclude; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.core.type.Field; @@ -34,6 +41,7 @@ import com.yahoo.elide.datastores.aggregation.metadata.enums.ValueType; import com.yahoo.elide.datastores.aggregation.query.DefaultMetricProjectionMaker; import com.yahoo.elide.datastores.aggregation.query.MetricProjectionMaker; +import com.yahoo.elide.datastores.aggregation.query.TableSQLMaker; import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery; import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable; import com.yahoo.elide.modelconfig.model.Argument; @@ -51,9 +59,12 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; @@ -62,12 +73,15 @@ * A dynamic Elide model that wraps a deserialized HJSON table. */ public class TableType implements Type { - public static final Pattern REFERENCE_PARENTHESES = Pattern.compile("\\{\\{(.+?)}}"); + private static final long serialVersionUID = 8197172323227250923L; private static final String SPACE = " "; + private static final String PERIOD = "."; + + public static final Pattern REFERENCE_PARENTHESES = Pattern.compile("\\{\\{(.+?)}}"); public static final Pattern NEWLINE = Pattern.compile(System.lineSeparator(), Pattern.LITERAL); protected Table table; - private Map, Annotation> annotations; + private transient Map, Annotation> annotations; private Map fields; private Package namespace; @@ -84,7 +98,7 @@ public TableType(Table table, Package namespace) { @Override public String getCanonicalName() { - return getName(); + return getPackage().getName().concat(PERIOD).concat(getName()); } @Override @@ -258,13 +272,9 @@ public boolean toOne() { private static Map, Annotation> buildAnnotations(Table table) { Map, Annotation> annotations = new HashMap<>(); - if (Boolean.TRUE.equals(table.getHidden())) { - annotations.put(Exclude.class, new ExcludeAnnotation()); - } else { - annotations.put(Include.class, getIncludeAnnotation(table)); - } + annotations.put(Include.class, getIncludeAnnotation(table)); - if (table.getSql() != null && !table.getSql().isEmpty()) { + if (StringUtils.isNotEmpty(table.getSql()) || StringUtils.isNotEmpty(table.getMaker())) { annotations.put(FromSubquery.class, new FromSubquery() { @Override @@ -277,6 +287,19 @@ public String sql() { return table.getSql(); } + @Override + public Class maker() { + if (StringUtils.isEmpty(table.getMaker())) { + return null; + } + + try { + return Class.forName(table.getMaker()).asSubclass(TableSQLMaker.class); + } catch (ClassNotFoundException e) { + throw new IllegalStateException(e); + } + } + @Override public String dbConnectionName() { return table.getDbConnectionName(); @@ -293,7 +316,7 @@ public Class annotationType() { @Override public String name() { String tableName = table.getTable(); - if (table.getSchema() != null && ! table.getSchema().isEmpty()) { + if (StringUtils.isNotEmpty(table.getSchema())) { return table.getSchema() + "." + tableName; } @@ -349,9 +372,14 @@ public boolean isFact() { return table.getIsFact(); } + @Override + public boolean isHidden() { + return table.getHidden() != null && table.getHidden(); + } + @Override public CardinalitySize size() { - if (table.getCardinality() == null || table.getCardinality().isEmpty()) { + if (StringUtils.isEmpty(table.getCardinality())) { return CardinalitySize.UNKNOWN; } return CardinalitySize.valueOf(table.getCardinality().toUpperCase(Locale.ENGLISH)); @@ -400,7 +428,7 @@ public String description() { @Override public ValueType type() { - return ValueType.valueOf(argument.getType().toString()); + return ValueType.valueOf(argument.getType().toUpperCase(Locale.ROOT)); } @Override @@ -415,7 +443,8 @@ public String[] values() { @Override public String defaultValue() { - return argument.getDefaultValue().toString(); + Object value = argument.getDefaultValue(); + return value == null ? null : value.toString(); } @Override @@ -429,10 +458,6 @@ public Class annotationType() { private static Map, Annotation> buildAnnotations(Measure measure) { Map, Annotation> annotations = new HashMap<>(); - if (Boolean.TRUE.equals(measure.getHidden())) { - annotations.put(Exclude.class, new ExcludeAnnotation()); - } - annotations.put(ColumnMeta.class, new ColumnMeta() { @Override public Class annotationType() { @@ -469,6 +494,11 @@ public String[] values() { return new String[0]; } + @Override + public boolean isHidden() { + return measure.getHidden() != null && measure.getHidden(); + } + @Override public String filterTemplate() { return measure.getFilterTemplate(); @@ -502,12 +532,12 @@ public String value() { @Override public Class maker() { - if (measure.getMaker() == null || measure.getMaker().isEmpty()) { + if (StringUtils.isEmpty(measure.getMaker())) { return DefaultMetricProjectionMaker.class; } try { - return (Class) Class.forName(measure.getMaker()); + return Class.forName(measure.getMaker()).asSubclass(MetricProjectionMaker.class); } catch (ClassNotFoundException e) { throw new IllegalStateException(e); } @@ -570,10 +600,6 @@ public String[] suggestionColumns() { private static Map, Annotation> buildAnnotations(Dimension dimension) { Map, Annotation> annotations = new HashMap<>(); - if (Boolean.TRUE.equals(dimension.getHidden())) { - annotations.put(Exclude.class, new ExcludeAnnotation()); - } - annotations.put(ColumnMeta.class, new ColumnMeta() { @Override public Class annotationType() { @@ -610,6 +636,11 @@ public String[] values() { return dimension.getValues().toArray(new String[0]); } + @Override + public boolean isHidden() { + return dimension.getHidden() != null && dimension.getHidden(); + } + @Override public String filterTemplate() { return dimension.getFilterTemplate(); @@ -617,7 +648,7 @@ public String filterTemplate() { @Override public CardinalitySize size() { - if (dimension.getCardinality() == null || dimension.getCardinality().isEmpty()) { + if (StringUtils.isEmpty(dimension.getCardinality())) { return CardinalitySize.UNKNOWN; } return CardinalitySize.valueOf(dimension.getCardinality().toUpperCase(Locale.ENGLISH)); @@ -657,7 +688,11 @@ public String expression() { }); } - if (dimension.getType() == TIME) { + if (dimension.getType().toUpperCase(Locale.ROOT).equals(ENUM_ORDINAL)) { + annotations.put(Enumerated.class, getEnumeratedAnnotation(EnumType.ORDINAL)); + } + + if (dimension.getType().toUpperCase(Locale.ROOT).equals(TIME)) { annotations.put(Temporal.class, new Temporal() { @Override @@ -709,6 +744,20 @@ public String timeZone() { return annotations; } + private static Enumerated getEnumeratedAnnotation(EnumType type) { + return new Enumerated() { + @Override + public Class annotationType() { + return Enumerated.class; + } + + @Override + public EnumType value() { + return EnumType.ORDINAL; + } + }; + } + private static Map buildFields(Table table) { Map fields = new HashMap<>(); fields.put("id", buildIdField()); @@ -790,6 +839,11 @@ public String[] values() { return new String[0]; } + @Override + public boolean isHidden() { + return false; + } + @Override public String filterTemplate() { return ""; @@ -804,11 +858,13 @@ public CardinalitySize size() { return new FieldType("id", LONG_TYPE, annotations); } - private static Type getFieldType(com.yahoo.elide.modelconfig.model.Type inputType) { - switch (inputType) { + private static Type getFieldType(String inputType) { + switch (inputType.toUpperCase(Locale.ROOT)) { case TIME: return TIME_TYPE; case TEXT: + case ENUM_ORDINAL: + case ENUM_TEXT: return STRING_TYPE; case MONEY: return BIGDECIMAL_TYPE; @@ -845,11 +901,9 @@ private static String replaceNewlineWithSpace(String str) { return (str == null) ? null : NEWLINE.matcher(str).replaceAll(SPACE); } - private static final class ExcludeAnnotation implements Exclude { - @Override - public Class annotationType() { - return Exclude.class; - } + @Override + public String toString() { + return String.format("TableType{ name=%s }", table.getGlobalName()); } private static Include getIncludeAnnotation(Table table) { @@ -881,4 +935,21 @@ public String name() { } }; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TableType tableType = (TableType) o; + return table.equals(tableType.table) && namespace.equals(tableType.namespace); + } + + @Override + public int hashCode() { + return Objects.hash(table, namespace); + } } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/filter/visitor/MatchesTemplateVisitor.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/filter/visitor/MatchesTemplateVisitor.java index 4d3f629114..d8e9f7d691 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/filter/visitor/MatchesTemplateVisitor.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/filter/visitor/MatchesTemplateVisitor.java @@ -5,6 +5,7 @@ */ package com.yahoo.elide.datastores.aggregation.filter.visitor; +import com.yahoo.elide.core.Path; import com.yahoo.elide.core.filter.expression.AndFilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.filter.expression.FilterExpressionVisitor; @@ -89,7 +90,7 @@ private boolean matches(FilterExpression a, FilterExpression b) { boolean valueMatches = predicateA.getValues().equals(predicateB.getValues()); boolean operatorMatches = predicateA.getOperator().equals(predicateB.getOperator()); - boolean pathMatches = predicateA.getPath().equals(predicateB.getPath()); + boolean pathMatches = pathMatches(predicateA.getPath(), predicateB.getPath()); boolean usingTemplate = false; @@ -114,6 +115,32 @@ private boolean matches(FilterExpression a, FilterExpression b) { return (operatorMatches && pathMatches && (valueMatches || usingTemplate)); } + private boolean pathMatches(Path a, Path b) { + if (a.getPathElements().size() != b.getPathElements().size()) { + return false; + } + + for (int idx = 0; idx < a.getPathElements().size(); idx++) { + Path.PathElement aElement = a.getPathElements().get(idx); + Path.PathElement bElement = b.getPathElements().get(idx); + + if (! aElement.getType().equals(bElement.getType())) { + return false; + } + + if (! aElement.getFieldName().equals(bElement.getFieldName())) { + return false; + } + + //We only compare path arguments if Path a (the template) has them. + if (aElement.getArguments().size() > 0 && ! aElement.getArguments().equals(bElement.getArguments())) { + return false; + } + } + + return true; + } + /** * Determines if a client filter matches or contains a subexpression that matches a template filter. * @param templateFilter A templated filter expression diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStore.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStore.java index 753c4fddce..99f6969e67 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStore.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStore.java @@ -7,6 +7,7 @@ import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; import static com.yahoo.elide.core.utils.TypeHelper.getClassType; +import static com.yahoo.elide.datastores.aggregation.AggregationDataStore.IS_FIELD_HIDDEN; import static com.yahoo.elide.datastores.aggregation.dynamic.NamespacePackage.DEFAULT; import static com.yahoo.elide.datastores.aggregation.dynamic.NamespacePackage.DEFAULT_NAMESPACE; import static com.yahoo.elide.datastores.aggregation.dynamic.NamespacePackage.EMPTY; @@ -22,7 +23,6 @@ import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.ClassScanner; import com.yahoo.elide.datastores.aggregation.AggregationDataStore; -import com.yahoo.elide.datastores.aggregation.annotation.Join; import com.yahoo.elide.datastores.aggregation.annotation.MetricFormula; import com.yahoo.elide.datastores.aggregation.dynamic.NamespacePackage; import com.yahoo.elide.datastores.aggregation.dynamic.TableType; @@ -45,7 +45,6 @@ import java.lang.annotation.Annotation; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -153,8 +152,8 @@ public MetaDataStore(ClassScanner scanner, Collection> modelsToBind, boolean en String version = EntityDictionary.getModelVersion(cls); HashMapDataStore hashMapDataStore = hashMapDataStores.computeIfAbsent(version, getHashMapDataStoreInitializer(scanner)); - hashMapDataStore.getDictionary().bindEntity(cls, Collections.singleton(Join.class)); - this.metadataDictionary.bindEntity(cls, Collections.singleton(Join.class)); + hashMapDataStore.getDictionary().bindEntity(cls, IS_FIELD_HIDDEN); + this.metadataDictionary.bindEntity(cls, IS_FIELD_HIDDEN); this.hashMapDataStores.putIfAbsent(version, hashMapDataStore); Include include = (Include) EntityDictionary.getFirstPackageAnnotation(cls, Arrays.asList(Include.class)); @@ -241,7 +240,7 @@ public MetaDataStore(ClassScanner scanner, Set> modelsToBind, boolean en public void populateEntityDictionary(EntityDictionary dictionary) { if (enableMetaDataStore) { metadataModelClasses.forEach( - cls -> dictionary.bindEntity(cls, Collections.singleton(Join.class)) + cls -> dictionary.bindEntity(cls, IS_FIELD_HIDDEN) ); } } @@ -265,8 +264,11 @@ public void addTable(Table table) { String version = table.getVersion(); EntityDictionary dictionary = hashMapDataStores.computeIfAbsent(version, SERVER_ERROR).getDictionary(); tables.put(dictionary.getEntityClass(table.getName(), version), table); - addMetaData(table, version); - table.getColumns().forEach(this::addColumn); + if (! table.isHidden()) { + addMetaData(table, version); + } + table.getAllColumns().stream() + .forEach(this::addColumn); table.getArgumentDefinitions().forEach(arg -> addArgument(arg, version)); } @@ -376,7 +378,9 @@ public Set getNamespacesToBind() { */ private void addColumn(Column column) { String version = column.getVersion(); - addMetaData(column, version); + if (! column.isHidden()) { + addMetaData(column, version); + } if (column instanceof TimeDimension) { TimeDimension timeDimension = (TimeDimension) column; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTransaction.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTransaction.java index b68d67190b..85a6c1c706 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTransaction.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTransaction.java @@ -6,6 +6,7 @@ package com.yahoo.elide.datastores.aggregation.metadata; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; import com.yahoo.elide.core.exceptions.BadRequestException; @@ -16,7 +17,6 @@ import java.io.IOException; import java.io.Serializable; import java.util.Map; -import java.util.Optional; import java.util.function.Function; /** @@ -60,16 +60,33 @@ public void createObject(Object entity, RequestScope scope) { } @Override - public Object getRelation(DataStoreTransaction relationTx, Object entity, Relationship relationship, - RequestScope scope) { + public DataStoreIterable getToManyRelation( + DataStoreTransaction relationTx, + Object entity, + Relationship relationship, + RequestScope scope + ) { return hashMapDataStores .computeIfAbsent(scope.getApiVersion(), REQUEST_ERROR) - .getDictionary() - .getValue(entity, relationship.getName(), scope); + .beginTransaction() + .getToManyRelation(relationTx, entity, relationship, scope); } @Override - public Iterable loadObjects(EntityProjection projection, RequestScope scope) { + public Object getToOneRelation( + DataStoreTransaction relationTx, + Object entity, + Relationship relationship, + RequestScope scope + ) { + return hashMapDataStores + .computeIfAbsent(scope.getApiVersion(), REQUEST_ERROR) + .beginTransaction() + .getToOneRelation(relationTx, entity, relationship, scope); + } + + @Override + public DataStoreIterable loadObjects(EntityProjection projection, RequestScope scope) { return hashMapDataStores .computeIfAbsent(scope.getApiVersion(), REQUEST_ERROR) .beginTransaction() @@ -89,21 +106,6 @@ public void close() throws IOException { // Do nothing } - @Override - public FeatureSupport supportsFiltering(RequestScope scope, Optional parent, EntityProjection projection) { - return FeatureSupport.NONE; - } - - @Override - public boolean supportsSorting(RequestScope scope, Optional parent, EntityProjection projection) { - return false; - } - - @Override - public boolean supportsPagination(RequestScope scope, Optional parent, EntityProjection projection) { - return false; - } - @Override public void cancel(RequestScope scope) { // Do nothing diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/enums/ValueType.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/enums/ValueType.java index 46da6e06fd..f952c4d00b 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/enums/ValueType.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/enums/ValueType.java @@ -73,7 +73,7 @@ public boolean matches(String input) { .build(); public static ValueType getScalarType(Type fieldClass) { - return SCALAR_TYPES.get(fieldClass); + return SCALAR_TYPES.getOrDefault(fieldClass, TEXT); } public static Type getType(ValueType valueType) { diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Column.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Column.java index 3d2e0eb1d7..04ac78df89 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Column.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Column.java @@ -58,6 +58,8 @@ public abstract class Column implements Versioned, Named, RequiresFilter { private final CardinalitySize cardinality; + private final boolean hidden; + @ToOne @ToString.Exclude @EqualsAndHashCode.Exclude @@ -71,7 +73,7 @@ public abstract class Column implements Versioned, Named, RequiresFilter { private final ValueSourceType valueSourceType; - private final Set values; + private final LinkedHashSet values; private String requiredFilter; @@ -101,6 +103,13 @@ protected Column(Table table, String fieldName, EntityDictionary dictionary) { this.id = constructColumnName(tableClass, fieldName, dictionary); this.name = fieldName; + String idField = dictionary.getIdFieldName(tableClass); + if (idField != null && idField.equals(fieldName)) { + this.hidden = false; + } else { + this.hidden = !dictionary.getAllExposedFields(tableClass).contains(fieldName); + } + ColumnMeta meta = dictionary.getAttributeOrRelationAnnotation(tableClass, ColumnMeta.class, fieldName); if (meta != null) { this.friendlyName = meta.friendlyName() != null && !meta.friendlyName().isEmpty() @@ -189,6 +198,10 @@ public static ValueType getValueType(Type tableClass, String fieldName, Entit return ValueType.TIME; } + if (fieldClass.isEnum()) { + return ValueType.TEXT; + } + return ValueType.getScalarType(fieldClass); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Namespace.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Namespace.java index 47040749c2..89eac7a7af 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Namespace.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Namespace.java @@ -7,6 +7,7 @@ import static com.yahoo.elide.datastores.aggregation.dynamic.NamespacePackage.DEFAULT; import com.yahoo.elide.annotation.ApiVersion; +import com.yahoo.elide.annotation.ComputedRelationship; import com.yahoo.elide.annotation.Exclude; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.core.dictionary.EntityDictionary; @@ -17,6 +18,7 @@ import java.util.HashSet; import java.util.Set; +import java.util.stream.Collectors; import javax.persistence.Id; import javax.persistence.OneToMany; @@ -43,11 +45,17 @@ public class Namespace { @Exclude private final NamespacePackage pkg; - @OneToMany @EqualsAndHashCode.Exclude @ToString.Exclude + @Exclude private Set tables; + @OneToMany + @ComputedRelationship + public Set
getTables() { + return tables.stream().filter(table -> !table.isHidden()).collect(Collectors.toSet()); + } + public Namespace(NamespacePackage pkg) { this.pkg = pkg; name = pkg.getName().isEmpty() ? DEFAULT : pkg.getName(); diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/RequiresFilter.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/RequiresFilter.java index 5039118cbd..8190b9b52d 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/RequiresFilter.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/RequiresFilter.java @@ -23,7 +23,10 @@ public interface RequiresFilter extends Named { default FilterExpression getRequiredFilter(EntityDictionary dictionary) { Type cls = dictionary.getEntityClass(getTable().getName(), getTable().getVersion()); - RSQLFilterDialect filterDialect = new RSQLFilterDialect(dictionary); + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder() + .dictionary(dictionary) + .addDefaultArguments(false) + .build(); if (StringUtils.isNotEmpty(getRequiredFilter())) { try { diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Table.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Table.java index a4f68d4713..dd9f56271a 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Table.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/metadata/models/Table.java @@ -7,8 +7,10 @@ import static com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore.isMetricField; import static com.yahoo.elide.datastores.aggregation.metadata.models.Column.getValueType; +import com.yahoo.elide.annotation.ComputedRelationship; import com.yahoo.elide.annotation.Exclude; import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.core.dictionary.EntityBinding; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.TypeHelper; @@ -67,23 +69,28 @@ public abstract class Table implements Versioned, Named, RequiresFilter { private final boolean isFact; + private final boolean hidden; + @ManyToOne private final Namespace namespace; - @OneToMany @ToString.Exclude + @EqualsAndHashCode.Exclude + @Exclude private final Set columns; - @OneToMany @ToString.Exclude + @EqualsAndHashCode.Exclude + @Exclude private final Set metrics; - @OneToMany @ToString.Exclude + @EqualsAndHashCode.Exclude + @Exclude private final Set dimensions; - @OneToMany - @ToString.Exclude + @EqualsAndHashCode.Exclude + @Exclude private final Set timeDimensions; @ToString.Exclude @@ -154,6 +161,7 @@ public Table(Namespace namespace, Type cls, EntityDictionary dictionary) { : name; this.description = meta.description(); this.category = meta.category(); + this.hidden = meta.isHidden(); this.requiredFilter = meta.filterTemplate(); this.tags = new HashSet<>(Arrays.asList(meta.tags())); this.hints = new LinkedHashSet<>(Arrays.asList(meta.hints())); @@ -169,6 +177,7 @@ public Table(Namespace namespace, Type cls, EntityDictionary dictionary) { this.friendlyName = name; this.description = null; this.category = null; + this.hidden = false; this.requiredFilter = null; this.tags = new HashSet<>(); this.hints = new LinkedHashSet<>(); @@ -177,6 +186,46 @@ public Table(Namespace namespace, Type cls, EntityDictionary dictionary) { } } + @OneToMany + @ComputedRelationship + public Set getColumns() { + return columns.stream().filter(column -> !column.isHidden()).collect(Collectors.toSet()); + } + + @OneToMany + @ComputedRelationship + public Set getDimensions() { + return dimensions.stream().filter(dimension -> !dimension.isHidden()).collect(Collectors.toSet()); + } + + @OneToMany + @ComputedRelationship + public Set getMetrics() { + return metrics.stream().filter(metric -> !metric.isHidden()).collect(Collectors.toSet()); + } + + @OneToMany + @ComputedRelationship + public Set getTimeDimensions() { + return timeDimensions.stream().filter(timeDimension -> !timeDimension.isHidden()).collect(Collectors.toSet()); + } + + public Set getAllColumns() { + return columns; + } + + public Set getAllDimensions() { + return dimensions; + } + + public Set getAllMetrics() { + return metrics; + } + + public Set getAllTimeDimensions() { + return timeDimensions; + } + private boolean isFact(Type cls, TableMeta meta) { if (meta != null) { return meta.isFact(); @@ -197,21 +246,29 @@ private boolean isFact(Type cls, TableMeta meta) { * @return all resolved column metadata */ private Set constructColumns(Type cls, EntityDictionary dictionary) { - Set columns = dictionary.getAllFields(cls).stream() - .filter(field -> { - ValueType valueType = getValueType(cls, field, dictionary); - return valueType != ValueType.UNKNOWN; - }) - .map(field -> { - if (isMetricField(dictionary, cls, field)) { - return constructMetric(field, dictionary); - } - if (dictionary.attributeOrRelationAnnotationExists(cls, field, Temporal.class)) { - return constructTimeDimension(field, dictionary); - } - return constructDimension(field, dictionary); - }) - .collect(Collectors.toSet()); + EntityBinding binding = dictionary.getEntityBinding(cls); + + Set columns = new HashSet<>(); + + //TODO - refactor entity binding to have classes for attributes & relationships. + binding.fieldsToValues.forEach((name, field) -> { + if (EntityBinding.isIdField(field)) { + return; + } + + ValueType valueType = getValueType(cls, name, dictionary); + if (valueType == ValueType.UNKNOWN) { + return; + } + + if (isMetricField(dictionary, cls, name)) { + columns.add(constructMetric(name, dictionary)); + } else if (dictionary.attributeOrRelationAnnotationExists(cls, name, Temporal.class)) { + columns.add(constructTimeDimension(name, dictionary)); + } else { + columns.add(constructDimension(name, dictionary)); + } + }); // add id field if exists if (dictionary.getIdFieldName(cls) != null) { diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/DefaultQueryPlanMerger.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/DefaultQueryPlanMerger.java new file mode 100644 index 0000000000..f64377e3b9 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/DefaultQueryPlanMerger.java @@ -0,0 +1,169 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.aggregation.query; + +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Streams; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Default implementation of logic to merge two or more query plans into one. + */ +public class DefaultQueryPlanMerger implements QueryPlanMerger { + MetaDataStore metaDataStore; + + public DefaultQueryPlanMerger(MetaDataStore metaDataStore) { + this.metaDataStore = metaDataStore; + } + + @Override + public boolean canMerge(QueryPlan a, QueryPlan b) { + Preconditions.checkNotNull(a); + Preconditions.checkNotNull(b); + + if (a.nestDepth() != b.nestDepth()) { + if (a.nestDepth() > b.nestDepth() && !b.canNest(metaDataStore)) { + return false; + } + + if (b.nestDepth() > a.nestDepth() && !a.canNest(metaDataStore)) { + return false; + } + + a = nestToDepth(a, b.nestDepth()); + b = nestToDepth(b, a.nestDepth()); + } + + boolean result = canMergeMetrics(a, b); + if (! result) { + return false; + } + + result = canMergeFilter(a, b); + if (! result) { + return false; + } + + result = canMergeDimensions(a, b); + if (! result) { + return false; + } + + result = canMergeTimeDimensions(a, b); + + if (!result) { + return false; + } + + if (a.isNested()) { + result = canMerge((QueryPlan) a.getSource(), (QueryPlan) b.getSource()); + } + + return result; + } + + protected boolean canMergeMetrics(QueryPlan a, QueryPlan b) { + /* + * Metrics can always coexist provided they have different aliases (which is enforced by the API). + */ + return true; + } + + protected boolean canMergeDimensions(QueryPlan a, QueryPlan b) { + for (DimensionProjection dimension : a.getDimensionProjections()) { + DimensionProjection otherDimension = b.getDimensionProjection(dimension.getName()); + + if (otherDimension != null && ! Objects.equals(otherDimension.getArguments(), dimension.getArguments())) { + return false; + } + } + return true; + } + + protected boolean canMergeTimeDimensions(QueryPlan a, QueryPlan b) { + for (TimeDimensionProjection dimension : a.getTimeDimensionProjections()) { + TimeDimensionProjection otherDimension = b.getTimeDimensionProjection(dimension.getName()); + + if (otherDimension != null && ! Objects.equals(otherDimension.getArguments(), dimension.getArguments())) { + return false; + } + } + return true; + } + + protected boolean canMergeFilter(QueryPlan a, QueryPlan b) { + if (! Objects.equals(a.getWhereFilter(), b.getWhereFilter())) { + return false; + } + return true; + } + + protected FilterExpression mergeFilter(QueryPlan a, QueryPlan b) { + return a.getWhereFilter(); + } + + protected Set mergeMetrics(QueryPlan a, QueryPlan b) { + return Streams.concat(b.getMetricProjections().stream(), + a.getMetricProjections().stream()).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + protected Set mergeDimension(QueryPlan a, QueryPlan b) { + return Streams.concat(b.getDimensionProjections().stream(), + a.getDimensionProjections().stream()).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + protected Set mergeTimeDimension(QueryPlan a, QueryPlan b) { + return Streams.concat(b.getTimeDimensionProjections().stream(), + a.getTimeDimensionProjections().stream()).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @Override + public QueryPlan merge(QueryPlan a, QueryPlan b) { + Preconditions.checkNotNull(a); + Preconditions.checkNotNull(b); + + a = nestToDepth(a, b.nestDepth()); + b = nestToDepth(b, a.nestDepth()); + + assert (a.isNested() || a.getSource().equals(b.getSource())); + + Set metrics = mergeMetrics(a, b); + Set timeDimensions = mergeTimeDimension(a, b); + Set dimensions = mergeDimension(a, b); + + if (!a.isNested()) { + return QueryPlan.builder() + .source(a.getSource()) + .metricProjections(metrics) + .dimensionProjections(dimensions) + .whereFilter(mergeFilter(a, b)) + .timeDimensionProjections(timeDimensions) + .build(); + } + Queryable mergedSource = merge((QueryPlan) a.getSource(), (QueryPlan) b.getSource()); + return QueryPlan.builder() + .source(mergedSource) + .metricProjections(metrics) + .dimensionProjections(dimensions) + .timeDimensionProjections(timeDimensions) + .build(); + } + + private QueryPlan nestToDepth(QueryPlan plan, int depth) { + while (plan.nestDepth() < depth) { + plan = plan.nest(metaDataStore); + } + return plan; + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/DefaultTableSQLMaker.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/DefaultTableSQLMaker.java new file mode 100644 index 0000000000..15ee6c0e71 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/DefaultTableSQLMaker.java @@ -0,0 +1,25 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.aggregation.query; + +import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.metadata.SQLTable; + +/** + * Default table SQL maker that just returns the SQL defined in the FromSubquery annotation. + */ +public class DefaultTableSQLMaker implements TableSQLMaker { + + @Override + public String make(Query clientQuery) { + SQLTable table = (SQLTable) clientQuery.getRoot(); + + FromSubquery fromSubquery = table.getCls().getAnnotation(FromSubquery.class); + + return fromSubquery.sql(); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryPlan.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryPlan.java index 9d64ef1c67..2f73a57ae3 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryPlan.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryPlan.java @@ -5,13 +5,13 @@ */ package com.yahoo.elide.datastores.aggregation.query; +import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; -import com.google.common.collect.Streams; import org.apache.commons.lang3.tuple.Pair; import lombok.Builder; +import lombok.Getter; import lombok.NonNull; import lombok.Singular; -import lombok.Value; import java.util.LinkedHashSet; import java.util.List; @@ -21,77 +21,30 @@ /** * A {@link QueryPlan} is a partial Query bound to a particular Metric. */ -@Value @Builder public class QueryPlan implements Queryable { @NonNull + @Getter private Queryable source; @Singular @NonNull + @Getter private List metricProjections; @Singular @NonNull + @Getter private List dimensionProjections; @Singular @NonNull + @Getter private List timeDimensionProjections; - /** - * Merges two query plans together. The order of merged metrics and dimensions is preserved such that - * the current plan metrics and dimensions come after the requested plan metrics and dimensions. - * @param other The other query to merge. - * @return A new merged query plan. - */ - public QueryPlan merge(QueryPlan other, MetaDataStore metaDataStore) { - QueryPlan self = this; - - if (other == null) { - return this; - } - - while (other.nestDepth() > self.nestDepth()) { - //TODO - update the reference table on each call to nest. - //Needed for nesting depth > 2 - self = self.nest(metaDataStore); - } - - while (self.nestDepth() > other.nestDepth()) { - //TODO - update the reference table on each call to nest. - //Needed for nesting depth > 2 - other = other.nest(metaDataStore); - } - - assert (self.isNested() || getSource().equals(other.getSource())); - - Set metrics = Streams.concat(other.metricProjections.stream(), - self.metricProjections.stream()).collect(Collectors.toCollection(LinkedHashSet::new)); - - Set timeDimensions = Streams.concat(other.timeDimensionProjections.stream(), - self.timeDimensionProjections.stream()).collect(Collectors.toCollection(LinkedHashSet::new)); - - Set dimensions = Streams.concat(other.dimensionProjections.stream(), - self.dimensionProjections.stream()).collect(Collectors.toCollection(LinkedHashSet::new)); - - if (!self.isNested()) { - return QueryPlan.builder() - .source(self.getSource()) - .metricProjections(metrics) - .dimensionProjections(dimensions) - .timeDimensionProjections(timeDimensions) - .build(); - } - Queryable mergedSource = ((QueryPlan) self.getSource()).merge((QueryPlan) other.getSource(), metaDataStore); - return QueryPlan.builder() - .source(mergedSource) - .metricProjections(metrics) - .dimensionProjections(dimensions) - .timeDimensionProjections(timeDimensions) - .build(); - } + @Getter + private FilterExpression whereFilter; /** * Tests whether or not this plan can be nested. @@ -131,6 +84,7 @@ public QueryPlan nest(MetaDataStore metaDataStore) { QueryPlan inner = QueryPlan.builder() .source(this.getSource()) + .whereFilter(whereFilter) .metricProjections(nestedMetrics.stream() .map(Pair::getRight) .flatMap(Set::stream) diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryPlanMerger.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryPlanMerger.java new file mode 100644 index 0000000000..1e606859f1 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryPlanMerger.java @@ -0,0 +1,58 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.aggregation.query; + +import java.util.LinkedList; +import java.util.List; + +/** + * Merges multiple query plans into a smaller set (one if possible). + */ +public interface QueryPlanMerger { + + /** + * Determines if two plans can be merged. + * @param a plan A. + * @param b plan B. + * @return True if the plans can be merged. False otherwise. + */ + public boolean canMerge(QueryPlan a, QueryPlan b); + + /** + * Merges two plans. + * @param a plan A. + * @param b plan B. + * @return A new query plan resulting from the merge of A and B. + */ + public QueryPlan merge(QueryPlan a, QueryPlan b); + + /** + * Collapses a list of query plans into a potentially smaller list of merged plans. + * @param plans The list of plans to merge. + * @return A list of merged plans - ideally containing a single merged plan. + */ + default List merge(List plans) { + QueryPlan mergedPlan = null; + + List result = new LinkedList<>(); + + for (QueryPlan plan: plans) { + if (mergedPlan == null) { + mergedPlan = plan; + } else { + if (canMerge(mergedPlan, plan)) { + mergedPlan = merge(plan, mergedPlan); + } else { + result.add(plan); + } + } + } + + result.add(0, mergedPlan); + return result; + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryResult.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryResult.java index 3f15444cdb..b78d4fb2c3 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryResult.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/QueryResult.java @@ -11,13 +11,17 @@ import lombok.NonNull; import lombok.Value; +import java.io.Serializable; + /** * A {@link QueryResult} contains the results from {@link QueryEngine#executeQuery(Query, QueryEngine.Transaction)}. * @param The type/model of data being returned. */ @Value @Builder -public class QueryResult { +public class QueryResult implements Serializable { + private static final long serialVersionUID = -3748307200186480683L; + @NonNull private Iterable data; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/Queryable.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/Queryable.java index b05c5959c1..67858a9b69 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/Queryable.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/Queryable.java @@ -13,7 +13,6 @@ import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialect; import com.yahoo.elide.datastores.aggregation.queryengines.sql.metadata.SQLJoin; -import com.yahoo.elide.datastores.aggregation.queryengines.sql.query.SQLColumnProjection; import com.google.common.collect.Streams; import java.util.ArrayList; @@ -24,6 +23,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; /** @@ -104,6 +104,18 @@ default ColumnProjection getColumnProjection(String name, Map .orElse(null); } + /** + * Retrieves a column by filter predicate + * @param filterPredicate A predicate to search for column projections. + * @return The column. + */ + default ColumnProjection getColumnProjection(Predicate filterPredicate) { + return getColumnProjections().stream() + .filter(filterPredicate) + .findFirst() + .orElse(null); + } + /** * Retrieves a non-time dimension by name. * @param name The name of the dimension. @@ -181,6 +193,10 @@ default List getFilterProjections( .collect(Collectors.toList()); } + default FilterExpression getWhereFilter() { + return null; + } + default List getFilterProjections(FilterExpression expression) { List results = new ArrayList<>(); if (expression == null) { @@ -198,7 +214,7 @@ default List getFilterProjections(FilterExpression expression) ColumnProjection projection = getSource().getColumnProjection(predicate.getField(), arguments); - results.add((SQLColumnProjection) projection); + results.add(projection); })); return results; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/TableSQLMaker.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/TableSQLMaker.java new file mode 100644 index 0000000000..f480c57f71 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/query/TableSQLMaker.java @@ -0,0 +1,17 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.aggregation.query; + +@FunctionalInterface +public interface TableSQLMaker { + /** + * Constructs dynamic SQL given a specific client query. + * @param clientQuery the client query. + * @return A templated SQL query + */ + String make(Query clientQuery); +} diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/ConnectionDetails.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/ConnectionDetails.java index 612d559698..569d210b36 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/ConnectionDetails.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/ConnectionDetails.java @@ -6,14 +6,17 @@ package com.yahoo.elide.datastores.aggregation.queryengines.sql; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialect; -import lombok.Value; + +import lombok.AllArgsConstructor; +import lombok.Data; import javax.sql.DataSource; /** * Custom class to abstract {@link DataSource} and {@link SQLDialect}. */ -@Value +@Data +@AllArgsConstructor public class ConnectionDetails { private DataSource dataSource; private SQLDialect dialect; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/EntityHydrator.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/EntityHydrator.java index 1d25c7ebcf..f22d5b8d20 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/EntityHydrator.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/EntityHydrator.java @@ -6,6 +6,7 @@ package com.yahoo.elide.datastores.aggregation.queryengines.sql; import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.InvalidValueException; import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.ParameterizedModel; @@ -13,6 +14,7 @@ import com.yahoo.elide.core.utils.coerce.CoerceUtil; import com.yahoo.elide.datastores.aggregation.QueryEngine; import com.yahoo.elide.datastores.aggregation.metadata.enums.ValueType; +import com.yahoo.elide.datastores.aggregation.metadata.models.Column; import com.yahoo.elide.datastores.aggregation.metadata.models.Table; import com.yahoo.elide.datastores.aggregation.query.ColumnProjection; import com.yahoo.elide.datastores.aggregation.query.MetricProjection; @@ -30,40 +32,44 @@ import com.yahoo.elide.datastores.aggregation.timegrains.Second; import com.yahoo.elide.datastores.aggregation.timegrains.Week; import com.yahoo.elide.datastores.aggregation.timegrains.Year; -import com.google.common.base.Preconditions; import org.apache.commons.lang3.mutable.MutableInt; import lombok.AccessLevel; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.NoSuchElementException; import java.util.stream.Collectors; /** * {@link EntityHydrator} hydrates the entity loaded by * {@link QueryEngine#executeQuery(Query, QueryEngine.Transaction)}. */ -public class EntityHydrator { +@Slf4j +public class EntityHydrator implements Iterable { @Getter(AccessLevel.PROTECTED) private final EntityDictionary entityDictionary; - @Getter(AccessLevel.PROTECTED) - private final List> results = new ArrayList<>(); - @Getter(AccessLevel.PRIVATE) private final Query query; - public EntityHydrator(ResultSet rs, Query query, EntityDictionary entityDictionary) { + private ResultSet resultSet; + + private Map projections; + + public EntityHydrator(ResultSet resultSet, Query query, EntityDictionary entityDictionary) { this.query = query; this.entityDictionary = entityDictionary; + this.resultSet = resultSet; //Get all the projections from the client query. - Map projections = this.query.getMetricProjections().stream() + projections = this.query.getMetricProjections().stream() .map(SQLMetricProjection.class::cast) .filter(SQLColumnProjection::isProjected) .filter(projection -> ! projection.getValueType().equals(ValueType.ID)) @@ -73,33 +79,6 @@ public EntityHydrator(ResultSet rs, Query query, EntityDictionary entityDictiona .map(SQLColumnProjection.class::cast) .filter(SQLColumnProjection::isProjected) .collect(Collectors.toMap(ColumnProjection::getAlias, ColumnProjection::getSafeAlias))); - - try { - Preconditions.checkArgument(projections.size() == rs.getMetaData().getColumnCount()); - while (rs.next()) { - Map row = new HashMap<>(); - - for (Map.Entry entry : projections.entrySet()) { - Object value = rs.getObject(entry.getValue()); - row.put(entry.getKey(), value); - } - - this.results.add(row); - } - } catch (SQLException e) { - throw new IllegalStateException(e); - } - } - - public Iterable hydrate() { - //Coerce the results into entity objects. - MutableInt counter = new MutableInt(0); - - List queryResults = getResults().stream() - .map((result) -> coerceObjectToEntity(result, counter)) - .collect(Collectors.toList()); - - return queryResults; } /** @@ -122,11 +101,24 @@ protected Object coerceObjectToEntity(Map result, MutableInt cou } result.forEach((fieldName, value) -> { - ColumnProjection dim = query.getColumnProjection(fieldName); - Type fieldType = getType(entityClass, dim); - Attribute attribute = projectionToAttribute(dim, fieldType); + ColumnProjection columnProjection = query.getColumnProjection(fieldName); + Column column = table.getColumn(Column.class, columnProjection.getName()); + + Type fieldType = getType(entityClass, columnProjection); + Attribute attribute = projectionToAttribute(columnProjection, fieldType); + + ValueType valueType = column.getValueType(); if (entityInstance instanceof ParameterizedModel) { + + // This is an ENUM_TEXT or ENUM_ORDINAL type. + if (! fieldType.isEnum() //Java enums can be coerced directly via CoerceUtil - so skip them. + && valueType == ValueType.TEXT + && column.getValues() != null + && !column.getValues().isEmpty()) { + value = convertToEnumValue(value, column.getValues()); + } + ((ParameterizedModel) entityInstance).addAttributeValue( attribute, CoerceUtil.coerce(value, fieldType)); @@ -191,4 +183,72 @@ private Type getType(Type modelType, ColumnProjection column) { throw new IllegalStateException("Invalid grain type"); } } + + private String convertToEnumValue(Object value, LinkedHashSet enumValues) { + if (value == null) { + return null; + } + + if (Integer.class.isAssignableFrom(value.getClass())) { + Integer valueIndex = (Integer) value; + if (valueIndex < enumValues.size()) { + return enumValues.toArray(new String[0])[valueIndex]; + } + } + else if (enumValues.contains(value.toString())) { + return value.toString(); + } + + throw new InvalidValueException(value, "Value must map to a value in: " + enumValues); + } + + @Override + public Iterator iterator() { + return new Iterator<> () { + + Object next = null; + MutableInt counter = new MutableInt(0); + + @Override + public boolean hasNext() { + if (next == null) { + try { + next = next(); + } catch (NoSuchElementException e) { + return false; + } + } + + return true; + } + + @Override + public Object next() { + + if (next != null) { + Object result = next; + next = null; + return result; + } + + try { + boolean hasNext = resultSet.next(); + if (! hasNext) { + throw new NoSuchElementException(); + } + Map row = new LinkedHashMap<>(); + + for (Map.Entry entry : projections.entrySet()) { + Object value = resultSet.getObject(entry.getValue()); + row.put(entry.getKey(), value); + } + + return coerceObjectToEntity(row, counter); + } catch (SQLException e) { + log.error("Error iterating over results {}", e.getMessage()); + } + throw new NoSuchElementException(); + } + }; + } } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SQLQueryEngine.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SQLQueryEngine.java index b6a4400653..48b0aec9d6 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SQLQueryEngine.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SQLQueryEngine.java @@ -11,7 +11,6 @@ import com.yahoo.elide.core.filter.predicates.FilterPredicate; import com.yahoo.elide.core.request.Argument; import com.yahoo.elide.core.request.Pagination; -import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.TimedFunction; import com.yahoo.elide.core.utils.coerce.CoerceUtil; @@ -21,16 +20,20 @@ import com.yahoo.elide.datastores.aggregation.dynamic.NamespacePackage; import com.yahoo.elide.datastores.aggregation.metadata.FormulaValidator; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.metadata.enums.ValueType; +import com.yahoo.elide.datastores.aggregation.metadata.models.Column; import com.yahoo.elide.datastores.aggregation.metadata.models.Dimension; import com.yahoo.elide.datastores.aggregation.metadata.models.Metric; import com.yahoo.elide.datastores.aggregation.metadata.models.Namespace; import com.yahoo.elide.datastores.aggregation.metadata.models.Table; import com.yahoo.elide.datastores.aggregation.metadata.models.TimeDimension; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; import com.yahoo.elide.datastores.aggregation.query.DimensionProjection; import com.yahoo.elide.datastores.aggregation.query.MetricProjection; import com.yahoo.elide.datastores.aggregation.query.Optimizer; import com.yahoo.elide.datastores.aggregation.query.Query; import com.yahoo.elide.datastores.aggregation.query.QueryPlan; +import com.yahoo.elide.datastores.aggregation.query.QueryPlanMerger; import com.yahoo.elide.datastores.aggregation.query.QueryResult; import com.yahoo.elide.datastores.aggregation.query.TimeDimensionProjection; import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery; @@ -48,7 +51,6 @@ import com.yahoo.elide.datastores.aggregation.validator.ColumnArgumentValidator; import com.yahoo.elide.datastores.aggregation.validator.TableArgumentValidator; import com.google.common.base.Preconditions; -import org.apache.commons.lang3.StringUtils; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -59,14 +61,14 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; import javax.sql.DataSource; /** @@ -76,44 +78,43 @@ public class SQLQueryEngine extends QueryEngine { @Getter - private final ConnectionDetails defaultConnectionDetails; - private final Map connectionDetailsMap; private final Set optimizers; private final QueryValidator validator; private final FormulaValidator formulaValidator; + private final Function connectionDetailsLookup; + private final QueryPlanMerger merger; - public SQLQueryEngine(MetaDataStore metaDataStore, ConnectionDetails defaultConnectionDetails) { - this(metaDataStore, defaultConnectionDetails, Collections.emptyMap(), new HashSet<>(), + public SQLQueryEngine(MetaDataStore metaDataStore, Function connectionDetailsLookup) { + this(metaDataStore, connectionDetailsLookup, new HashSet<>(), new DefaultQueryPlanMerger(metaDataStore), new DefaultQueryValidator(metaDataStore.getMetadataDictionary())); } /** * Constructor. * @param metaDataStore : MetaDataStore. - * @param defaultConnectionDetails : default DataSource Object and SQLDialect Object. - * @param connectionDetailsMap : Connection Name to DataSource Object and SQL Dialect Object mapping. + * @param connectionDetailsLookup : maps a connection name to meta info about the connection. * @param optimizers The set of enabled optimizers. + * @param merger Merges multiple plans into a smaller set (one if possible) * @param validator Validates each incoming client query. */ public SQLQueryEngine( MetaDataStore metaDataStore, - ConnectionDetails defaultConnectionDetails, - Map connectionDetailsMap, + Function connectionDetailsLookup, Set optimizers, + QueryPlanMerger merger, QueryValidator validator ) { - Preconditions.checkNotNull(defaultConnectionDetails); - Preconditions.checkNotNull(connectionDetailsMap); + Preconditions.checkNotNull(connectionDetailsLookup); - this.defaultConnectionDetails = defaultConnectionDetails; - this.connectionDetailsMap = connectionDetailsMap; + this.connectionDetailsLookup = connectionDetailsLookup; this.metaDataStore = metaDataStore; this.validator = validator; this.formulaValidator = new FormulaValidator(metaDataStore); this.metadataDictionary = metaDataStore.getMetadataDictionary(); populateMetaData(metaDataStore); this.optimizers = optimizers; + this.merger = merger; } private static final Function SINGLE_RESULT_MAPPER = rs -> { @@ -143,15 +144,7 @@ protected Table constructTable(Namespace namespace, Type entityClass, EntityD dbConnectionName = ((FromSubquery) annotation).dbConnectionName(); } - ConnectionDetails connectionDetails; - if (StringUtils.isBlank(dbConnectionName)) { - connectionDetails = defaultConnectionDetails; - } else { - connectionDetails = Optional.ofNullable(connectionDetailsMap.get(dbConnectionName)) - .orElseThrow(() -> new IllegalStateException("ConnectionDetails undefined for model: " - + metaDataDictionary.getJsonAliasFor(entityClass))); - } - + ConnectionDetails connectionDetails = connectionDetailsLookup.apply(dbConnectionName); return new SQLTable(namespace, entityClass, metaDataDictionary, connectionDetails); } @@ -186,7 +179,7 @@ protected void verifyMetaData(MetaDataStore metaDataStore) { TableArgumentValidator tableArgValidator = new TableArgumentValidator(metaDataStore, sqlTable); tableArgValidator.validate(); - sqlTable.getColumns().forEach(column -> { + sqlTable.getAllColumns().forEach(column -> { ColumnArgumentValidator colArgValidator = new ColumnArgumentValidator(metaDataStore, sqlTable, column); colArgValidator.validate(); }); @@ -266,7 +259,7 @@ public QueryResult executeQuery(Query query, Transaction transaction) { Pagination pagination = query.getPagination(); if (returnPageTotals(pagination)) { - resultBuilder.pageTotals(getPageTotal(expandedQuery, sql, sqlTransaction)); + resultBuilder.pageTotals(getPageTotal(expandedQuery, sql, query, sqlTransaction)); } log.debug("SQL Query: " + queryString); @@ -278,15 +271,15 @@ public QueryResult executeQuery(Query query, Transaction transaction) { // Run the primary query and log the time spent. ResultSet resultSet = runQuery(stmt, queryString, Function.identity()); - resultBuilder.data(new EntityHydrator(resultSet, query, metadataDictionary).hydrate()); + resultBuilder.data(new EntityHydrator(resultSet, query, metadataDictionary)); return resultBuilder.build(); } - private long getPageTotal(Query query, NativeQuery sql, SqlTransaction sqlTransaction) { - ConnectionDetails details = query.getConnectionDetails(); + private long getPageTotal(Query expandedQuery, NativeQuery sql, Query clientQuery, SqlTransaction sqlTransaction) { + ConnectionDetails details = expandedQuery.getConnectionDetails(); DataSource dataSource = details.getDataSource(); SQLDialect dialect = details.getDialect(); - NativeQuery paginationSQL = toPageTotalSQL(query, sql, dialect); + NativeQuery paginationSQL = toPageTotalSQL(expandedQuery, sql, dialect); if (paginationSQL == null) { // The query returns the aggregated metric without any dimension. @@ -297,7 +290,7 @@ private long getPageTotal(Query query, NativeQuery sql, SqlTransaction sqlTransa NamedParamPreparedStatement stmt = sqlTransaction.initializeStatement(paginationSQL.toString(), dataSource); // Supply the query parameters to the query - supplyFilterQueryParameters(query, stmt, dialect); + supplyFilterQueryParameters(clientQuery, stmt, dialect); // Run the Pagination query and log the time spent. Long result = CoerceUtil.coerce(runQuery(stmt, paginationSQL.toString(), SINGLE_RESULT_MAPPER), Long.class); @@ -390,22 +383,24 @@ private NativeQuery toSQL(Query query, SQLDialect sqlDialect) { * @return A query that reflects each metric's individual query plan. */ private Query expandMetricQueryPlans(Query query) { - QueryPlan mergedPlan = null; - - //Expand each metric into its own query plan. Merge them all together. - for (MetricProjection metricProjection : query.getMetricProjections()) { - QueryPlan queryPlan = metricProjection.resolve(query); - if (queryPlan != null) { - if (mergedPlan != null && mergedPlan.isNested() && !queryPlan.canNest(metaDataStore)) { - //TODO - Run multiple queries. - throw new UnsupportedOperationException("Cannot merge a nested query with a metric that " - + "doesn't support nesting"); - } - mergedPlan = queryPlan.merge(mergedPlan, metaDataStore); - } + + //Expand each metric into its own query plan. + List toMerge = query.getMetricProjections().stream() + .map(projection -> projection.resolve(query)) + .collect(Collectors.toList()); + + //Merge all the queries together. + List mergedPlans = merger.merge(toMerge); + + //TODO - Support joins across plans rather than rejecting plans that don't merge. + if (mergedPlans.size() != 1) { + throw new UnsupportedOperationException("Incompatible metrics in client query. Cannot merge " + + "into a single query"); } - QueryPlanTranslator queryPlanTranslator = new QueryPlanTranslator(query, metaDataStore); + QueryPlan mergedPlan = mergedPlans.get(0); + + QueryPlanTranslator queryPlanTranslator = new QueryPlanTranslator(query, metaDataStore, merger); Query merged = (mergedPlan == null) ? QueryPlanTranslator.addHiddenProjections(metaDataStore, query).build() @@ -451,15 +446,15 @@ private void supplyFilterQueryParameters(Query query, NamedParamPreparedStatemen } for (FilterPredicate filterPredicate : predicates) { - boolean isTimeFilter = ClassType.of(Time.class).isAssignableFrom(filterPredicate.getFieldType()); + Column column = metaDataStore.getColumn(filterPredicate.getEntityType(), filterPredicate.getField()); if (filterPredicate.getOperator().isParameterized()) { boolean shouldEscape = filterPredicate.isMatchingOperator(); filterPredicate.getParameters().forEach(param -> { try { Object value = param.getValue(); - if (isTimeFilter) { - value = dialect.translateTimeToJDBC((Time) value); - } + + value = convertForJdbc(filterPredicate.getEntityType(), column, value, dialect); + stmt.setObject(param.getName(), shouldEscape ? param.escapeMatching() : value); } catch (SQLException e) { throw new IllegalStateException(e); @@ -469,6 +464,45 @@ private void supplyFilterQueryParameters(Query query, NamedParamPreparedStatemen } } + private Object convertForJdbc(Type parent, Column column, Object value, SQLDialect dialect) { + if (column.getValueType().equals(ValueType.TIME) && (Time.class).isAssignableFrom(value.getClass())) { + return dialect.translateTimeToJDBC((Time) value); + } + + if (value.getClass().isEnum()) { + Enumerated enumerated = + metadataDictionary.getAttributeOrRelationAnnotation(parent, Enumerated.class, column.getName()); + + if (enumerated != null && enumerated.value().equals(EnumType.ORDINAL)) { + return ((Enum) value).ordinal(); + } else { + return value.toString(); + } + } + + if ((column.getValueType().equals(ValueType.TEXT) + && column.getValues() != null + && column.getValues().isEmpty() == false)) { + Enumerated enumerated = + metadataDictionary.getAttributeOrRelationAnnotation(parent, Enumerated.class, column.getName()); + + if (enumerated != null && enumerated.value().equals(EnumType.ORDINAL)) { + + String [] enumValues = column.getValues().toArray(new String[0]); + for (int idx = 0; idx < column.getValues().size(); idx++) { + if (enumValues[idx].equals(value)) { + return idx; + } + } + + throw new IllegalStateException(String.format("Invalid value %s for column %s", + value, column.getName())); + } + } + + return value; + } + /** * Takes a SQLQuery and creates a new clone that instead returns the total number of records of the original * query. diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/annotation/FromSubquery.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/annotation/FromSubquery.java index 59ffd2e2fc..76815dc03f 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/annotation/FromSubquery.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/annotation/FromSubquery.java @@ -5,6 +5,9 @@ */ package com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation; +import com.yahoo.elide.datastores.aggregation.query.DefaultTableSQLMaker; +import com.yahoo.elide.datastores.aggregation.query.TableSQLMaker; + import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Inherited; @@ -20,7 +23,6 @@ @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface FromSubquery { - /** * The SQL subquery. * @@ -28,6 +30,13 @@ */ String sql(); + /** + * Generates the subquery SQL dynamically + * + * @return The class of the subquery generator. + */ + Class maker() default DefaultTableSQLMaker.class; + /** * DB Connection Name for this query. * @return String DB Connection Name diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteInnerAggregationExtractor.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteInnerAggregationExtractor.java index 9c637594d9..6dfb21d9f9 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteInnerAggregationExtractor.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteInnerAggregationExtractor.java @@ -48,6 +48,9 @@ public List> visit(SqlCall call) { } for (SqlNode node : call.getOperandList()) { + if (node == null) { + continue; + } List> operandResults = node.accept(this); if (operandResults != null) { result.addAll(operandResults); @@ -60,6 +63,9 @@ public List> visit(SqlCall call) { public List> visit(SqlNodeList nodeList) { List> result = new ArrayList<>(); for (SqlNode node : nodeList) { + if (node == null) { + continue; + } List> inner = node.accept(this); if (inner != null) { result.addAll(inner); diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteOuterAggregationExtractor.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteOuterAggregationExtractor.java index 49f07e778b..fd502edfae 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteOuterAggregationExtractor.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteOuterAggregationExtractor.java @@ -17,11 +17,13 @@ import org.apache.calcite.sql.SqlNodeList; import org.apache.calcite.sql.parser.SqlParseException; import org.apache.calcite.sql.parser.SqlParser; +import org.apache.calcite.sql.parser.SqlParserPos; import org.apache.calcite.sql.util.SqlBasicVisitor; import java.util.ArrayDeque; import java.util.List; import java.util.Queue; +import java.util.stream.Collectors; /** * Parses a column expression and rewrites the post aggregation expression AST to reference @@ -58,7 +60,7 @@ public SqlNode visit(SqlCall call) { } for (int idx = 0; idx < call.getOperandList().size(); idx++) { SqlNode operand = call.getOperandList().get(idx); - call.setOperand(idx, operand.accept(this)); + call.setOperand(idx, operand == null ? null : operand.accept(this)); } return call; @@ -66,7 +68,11 @@ public SqlNode visit(SqlCall call) { @Override public SqlNode visit(SqlNodeList nodeList) { - return nodeList; + + return SqlNodeList.of(SqlParserPos.ZERO, + nodeList.getList().stream() + .map(sqlNode -> sqlNode == null ? null : sqlNode.accept(this)) + .collect(Collectors.toList())); } @Override diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/SupportedAggregation.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/SupportedAggregation.java index 18ab334d1a..6a8d936be5 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/SupportedAggregation.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/SupportedAggregation.java @@ -33,7 +33,7 @@ public class SupportedAggregation { @NonNull private String outerTemplate; - public List getInnerAggregations(String ...operands) { + public List getInnerAggregations(String ... operands) { List innerAggregations = new ArrayList<>(); for (String innerTemplate : innerTemplates) { diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/dialects/SQLDialectFactory.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/dialects/SQLDialectFactory.java index cc21d88e76..ad37ff6c3f 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/dialects/SQLDialectFactory.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/dialects/SQLDialectFactory.java @@ -72,7 +72,7 @@ public static SQLDialect getDialect(String type) { return DRUID_DIALECT; } try { - return (SQLDialect) Class.forName(type).getConstructor().newInstance(); + return Class.forName(type).asSubclass(SQLDialect.class).getConstructor().newInstance(); } catch (ClassNotFoundException e) { throw new IllegalArgumentException("Unsupported SQL Dialect: " + type, e); } catch (Exception e) { diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/dialects/impl/H2Dialect.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/dialects/impl/H2Dialect.java index 840f53008b..36228a3fd8 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/dialects/impl/H2Dialect.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/dialects/impl/H2Dialect.java @@ -6,9 +6,13 @@ package com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.AbstractSqlDialect; +import com.yahoo.elide.datastores.aggregation.timegrains.Time; import org.apache.calcite.avatica.util.Casing; import org.apache.calcite.sql.SqlDialect; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + /** * H2 SQLDialect. */ @@ -23,6 +27,12 @@ public String generateOffsetLimitClause(int offset, int limit) { return LIMIT + limit + SPACE + OFFSET + offset; } + @Override + public Object translateTimeToJDBC(Time time) { + OffsetDateTime offsetDateTIme = OffsetDateTime.ofInstant(time.toInstant(), ZoneOffset.systemDefault()); + return offsetDateTIme; + } + @Override public String getFullJoinKeyword() { throw new IllegalArgumentException("Full Join is not supported for: " + getDialectType()); diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/ExpressionParser.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/ExpressionParser.java index 3db429a5a0..c973269fb1 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/ExpressionParser.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/ExpressionParser.java @@ -40,7 +40,7 @@ */ public class ExpressionParser { - private static final Pattern REFERENCE_PARENTHESES = Pattern.compile("\\{\\{(.+?)}}"); + private static final Pattern REFERENCE_PARENTHESES = Pattern.compile("\\{\\{\\[?(.+?)\\]?}}"); private static final String SQL_HELPER_PREFIX = "sql "; private static final String COLUMN_ARGS_PREFIX = "$$column.args."; private static final String TABLE_ARGS_PREFIX = "$$table.args."; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/JoinExpressionExtractor.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/JoinExpressionExtractor.java index ce64a12817..3c7ed68413 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/JoinExpressionExtractor.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/JoinExpressionExtractor.java @@ -22,6 +22,7 @@ import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; import com.yahoo.elide.datastores.aggregation.metadata.TableContext; import com.yahoo.elide.datastores.aggregation.query.ColumnProjection; +import com.yahoo.elide.datastores.aggregation.query.Query; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialect; import com.yahoo.elide.datastores.aggregation.queryengines.sql.metadata.SQLJoin; import com.yahoo.elide.datastores.aggregation.queryengines.sql.metadata.SQLTable; @@ -42,12 +43,14 @@ public class JoinExpressionExtractor implements ReferenceVisitor> { private final Set joinExpressions = new LinkedHashSet<>(); private final ColumnContext context; + private final Query clientQuery; private final MetaDataStore metaDataStore; private final EntityDictionary dictionary; - public JoinExpressionExtractor(ColumnContext context) { + public JoinExpressionExtractor(ColumnContext context, Query clientQuery) { this.context = context; + this.clientQuery = clientQuery; this.metaDataStore = context.getMetaDataStore(); this.dictionary = context.getMetaDataStore().getMetadataDictionary(); } @@ -74,7 +77,7 @@ public Set visitLogicalReference(LogicalReference reference) { .tableArguments(this.context.getTableArguments()) .build(); - JoinExpressionExtractor visitor = new JoinExpressionExtractor(newCtx); + JoinExpressionExtractor visitor = new JoinExpressionExtractor(newCtx, clientQuery); reference.getReferences().forEach(ref -> { joinExpressions.addAll(ref.accept(visitor)); }); @@ -151,7 +154,7 @@ public Set visitJoinReference(JoinReference reference) { // If reference within current join reference is of type PhysicalReference, then below visitor doesn't matter. // If it is of type LogicalReference, then visitLogicalReference method will recreate visitor with correct // value of ColumnProjection in context. - JoinExpressionExtractor visitor = new JoinExpressionExtractor(currentCtx); + JoinExpressionExtractor visitor = new JoinExpressionExtractor(currentCtx, clientQuery); joinExpressions.addAll(reference.getReference().accept(visitor)); return joinExpressions; } @@ -167,11 +170,12 @@ private String constructTableOrSubselect(ColumnContext columnCtx, Type cls) { if (hasSql(cls)) { // Resolve any table arguments with in FromSubquery or Subselect TableContext context = TableContext.builder().tableArguments(columnCtx.getTableArguments()).build(); - String selectSql = context.resolve(resolveTableOrSubselect(dictionary, cls)); + String selectSql = context.resolve(resolveTableOrSubselect(dictionary, cls, clientQuery)); return OPEN_BRACKET + selectSql + CLOSE_BRACKET; } - return applyQuotes(resolveTableOrSubselect(dictionary, cls), columnCtx.getQueryable().getDialect()); + return applyQuotes(resolveTableOrSubselect(dictionary, cls, clientQuery), + columnCtx.getQueryable().getDialect()); } @Override diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/metadata/SQLTable.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/metadata/SQLTable.java index 114227c89e..dc3266b746 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/metadata/SQLTable.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/metadata/SQLTable.java @@ -26,7 +26,9 @@ import com.yahoo.elide.datastores.aggregation.query.DimensionProjection; import com.yahoo.elide.datastores.aggregation.query.MetricProjection; import com.yahoo.elide.datastores.aggregation.query.MetricProjectionMaker; +import com.yahoo.elide.datastores.aggregation.query.Query; import com.yahoo.elide.datastores.aggregation.query.Queryable; +import com.yahoo.elide.datastores.aggregation.query.TableSQLMaker; import com.yahoo.elide.datastores.aggregation.query.TimeDimensionProjection; import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery; @@ -59,6 +61,8 @@ public class SQLTable extends Table implements Queryable { private Map arguments; + private Type cls; + public SQLTable(Namespace namespace, Type cls, EntityDictionary dictionary, @@ -66,6 +70,7 @@ public SQLTable(Namespace namespace, super(namespace, cls, dictionary); this.connectionDetails = connectionDetails; this.joins = new HashMap<>(); + this.cls = cls; this.arguments = prepareArgMap(getArgumentDefinitions()); EntityBinding binding = dictionary.getEntityBinding(cls); @@ -141,7 +146,7 @@ public MetricProjection getMetricProjection(Metric metric, String alias, Map getMetricProjections() { - return super.getMetrics().stream() + return super.getAllMetrics().stream() .map(metric -> getMetricProjection(metric, metric.getName(), prepareArgMap(metric.getArgumentDefinitions()))) .collect(Collectors.toList()); @@ -175,7 +180,7 @@ public DimensionProjection getDimensionProjection(Dimension dimension, String al @Override public List getDimensionProjections() { - return super.getDimensions() + return super.getAllDimensions() .stream() .map(dimension -> getDimensionProjection(dimension, dimension.getName(), prepareArgMap(dimension.getArgumentDefinitions()))) @@ -213,7 +218,7 @@ public TimeDimensionProjection getTimeDimensionProjection(TimeDimension dimensio @Override public List getTimeDimensionProjections() { - return super.getTimeDimensions() + return super.getAllTimeDimensions() .stream() .map(dimension -> getTimeDimensionProjection(dimension, dimension.getName(), prepareArgMap(dimension.getArgumentDefinitions()))) @@ -222,7 +227,7 @@ public List getTimeDimensionProjections() { @Override public List getColumnProjections() { - return super.getColumns() + return super.getAllColumns() .stream() .map(column -> getColumnProjection(column.getName())) .collect(Collectors.toList()); @@ -336,12 +341,18 @@ public static boolean hasSql(Type cls) { * Maps an entity class to a physical table or subselect query, if neither {@link javax.persistence.Table} * nor {@link Subselect} annotation is present on this class, try {@link FromTable} and {@link FromSubquery}. * @param cls The entity class. + * @param clientQuery the client query. * @return The physical SQL table or subselect query. */ - public static String resolveTableOrSubselect(EntityDictionary dictionary, Type cls) { + public static String resolveTableOrSubselect(EntityDictionary dictionary, Type cls, Query clientQuery) { if (hasSql(cls)) { if (cls.isAnnotationPresent(FromSubquery.class)) { - return dictionary.getAnnotation(cls, FromSubquery.class).sql(); + FromSubquery fromSubquery = dictionary.getAnnotation(cls, FromSubquery.class); + if (fromSubquery.maker() != null) { + TableSQLMaker maker = dictionary.getInjector().instantiate(fromSubquery.maker()); + return maker.make(clientQuery); + } + return fromSubquery.sql(); } return dictionary.getAnnotation(cls, Subselect.class).value(); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/QueryPlanTranslator.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/QueryPlanTranslator.java index 29c56c7aef..3f7ce76ad5 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/QueryPlanTranslator.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/QueryPlanTranslator.java @@ -6,10 +6,14 @@ package com.yahoo.elide.datastores.aggregation.queryengines.sql.query; +import com.yahoo.elide.core.filter.expression.AndFilterExpression; +import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; import com.yahoo.elide.datastores.aggregation.query.ColumnProjection; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; import com.yahoo.elide.datastores.aggregation.query.Query; import com.yahoo.elide.datastores.aggregation.query.QueryPlan; +import com.yahoo.elide.datastores.aggregation.query.QueryPlanMerger; import com.yahoo.elide.datastores.aggregation.query.QueryVisitor; import com.yahoo.elide.datastores.aggregation.query.Queryable; import com.yahoo.elide.datastores.aggregation.queryengines.sql.expression.ExpressionParser; @@ -35,10 +39,16 @@ public class QueryPlanTranslator implements QueryVisitor { private boolean invoked = false; private MetaDataStore metaDataStore; + private QueryPlanMerger merger; - public QueryPlanTranslator(Query clientQuery, MetaDataStore metaDataStore) { + public QueryPlanTranslator(Query clientQuery, MetaDataStore metaDataStore, QueryPlanMerger merger) { this.metaDataStore = metaDataStore; this.clientQuery = clientQuery; + this.merger = merger; + } + + public QueryPlanTranslator(Query clientQuery, MetaDataStore metaDataStore) { + this(clientQuery, metaDataStore, new DefaultQueryPlanMerger(metaDataStore)); } /** @@ -60,7 +70,7 @@ public Query translate(QueryPlan plan) { throw new UnsupportedOperationException("Cannot nest one or more dimensions from the client query"); } - QueryPlan merged = clientQueryPlan.merge(plan, metaDataStore); + QueryPlan merged = merger.merge(clientQueryPlan, plan); //Decorate the result with filters, sorting, and pagination. return merged.accept(this).build(); @@ -92,7 +102,7 @@ private Query.QueryBuilder visitInnerQueryPlan(Queryable plan) { .metricProjections(plan.getMetricProjections()) .dimensionProjections(plan.getDimensionProjections()) .timeDimensionProjections(plan.getTimeDimensionProjections()) - .whereFilter(clientQuery.getWhereFilter()) + .whereFilter(mergeFilters(plan, clientQuery)) .arguments(clientQuery.getArguments()); return addHiddenProjections(metaDataStore, builder, clientQuery); @@ -134,7 +144,7 @@ private Query.QueryBuilder visitUnnestedQueryPlan(Queryable plan) { .dimensionProjections(plan.getDimensionProjections()) .timeDimensionProjections(plan.getTimeDimensionProjections()) .havingFilter(clientQuery.getHavingFilter()) - .whereFilter(clientQuery.getWhereFilter()) + .whereFilter(mergeFilters(plan, clientQuery)) .sorting(clientQuery.getSorting()) .pagination(clientQuery.getPagination()) .scope(clientQuery.getScope()) @@ -171,7 +181,7 @@ public static Query.QueryBuilder addHiddenProjections( directReferencedColumns.stream(), indirectReferenceColumns.stream() ).forEach(column -> { - if (query.getColumnProjection(column.getName(), column.getArguments()) == null) { + if (query.getColumnProjection(column.getAlias(), column.getArguments()) == null) { builder.column(column.withProjected(false)); } }); @@ -194,4 +204,20 @@ public static Query.QueryBuilder addHiddenProjections(MetaDataStore metaDataStor return addHiddenProjections(metaDataStore, builder, query); } + + private static FilterExpression mergeFilters(Queryable a, Queryable b) { + if (a.getWhereFilter() == null && b.getWhereFilter() == null) { + return null; + } + + if (a.getWhereFilter() == null) { + return b.getWhereFilter(); + } + + if (b.getWhereFilter() == null) { + return a.getWhereFilter(); + } + + return new AndFilterExpression(a.getWhereFilter(), b.getWhereFilter()); + } } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/QueryTranslator.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/QueryTranslator.java index ac316cda73..4801250e81 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/QueryTranslator.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/QueryTranslator.java @@ -23,6 +23,7 @@ import com.yahoo.elide.datastores.aggregation.query.Query; import com.yahoo.elide.datastores.aggregation.query.QueryVisitor; import com.yahoo.elide.datastores.aggregation.query.Queryable; +import com.yahoo.elide.datastores.aggregation.query.TableSQLMaker; import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery; import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialect; @@ -134,11 +135,21 @@ public NativeQuery.NativeQueryBuilder visitQueryable(Queryable table) { TableContext context = TableContext.builder().tableArguments(clientQuery.getArguments()).build(); - String tableStatement = tableCls.isAnnotationPresent(FromSubquery.class) - ? "(" + context.resolve(tableCls.getAnnotation(FromSubquery.class).sql()) + ")" - : tableCls.isAnnotationPresent(FromTable.class) - ? applyQuotes(tableCls.getAnnotation(FromTable.class).name()) - : applyQuotes(table.getName()); + String tableStatement; + if (tableCls.isAnnotationPresent(FromSubquery.class)) { + FromSubquery fromSubquery = tableCls.getAnnotation(FromSubquery.class); + Class makerClass = fromSubquery.maker(); + if (makerClass != null) { + TableSQLMaker maker = dictionary.getInjector().instantiate(makerClass); + tableStatement = "(" + context.resolve(maker.make(clientQuery)) + ")"; + } else { + tableStatement = "(" + context.resolve(fromSubquery.sql()) + ")"; + } + } else { + tableStatement = tableCls.isAnnotationPresent(FromTable.class) + ? applyQuotes(tableCls.getAnnotation(FromTable.class).name()) + : applyQuotes(table.getName()); + } return builder.fromClause(getFromClause(tableStatement, tableAlias, dialect)); } @@ -235,7 +246,7 @@ private String extractOrderBy(Map sortClauses, Query qu /** * Coverts a Path from a table to a join path. - * @param source The table being queried. + * @param query query * @param path The path object from the table that may contain a join. * @return */ @@ -247,7 +258,7 @@ private Set extractJoinExpressions(Query query, Path path) { /** * Given a filter expression, extracts any entity relationship traversals that require joins. * - * @param source The table that is being queried. + * @param query query * @param expression The filter expression * @return A set of Join expressions that capture a relationship traversal. */ @@ -264,7 +275,7 @@ private Set extractJoinExpressions(Query query, FilterExpression express /** * Given a list of columns to sort on, extracts any entity relationship traversals that require joins. * - * @param source The table that is being queried. + * @param query query * @param sortClauses The list of sort columns and their sort order (ascending or descending). * @return A set of Join expressions that capture a relationship traversal. */ @@ -305,7 +316,7 @@ private Set extractJoinExpressions(ColumnProjection column, Query query) .tableArguments(query.getArguments()) .build(); - JoinExpressionExtractor visitor = new JoinExpressionExtractor(context); + JoinExpressionExtractor visitor = new JoinExpressionExtractor(context, clientQuery); List references = parser.parse(query.getSource(), column); references.forEach(ref -> joinExpressions.addAll(ref.accept(visitor))); return joinExpressions; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLColumnProjection.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLColumnProjection.java index 4ef9a352a9..e19f340626 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLColumnProjection.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLColumnProjection.java @@ -111,7 +111,8 @@ default Pair> nest(Queryable source, boolean requiresJoin = requiresJoin(references); - boolean inProjection = source.getColumnProjection(getName(), getArguments(), true) != null; + String columnId = source.isRoot() ? getName() : getAlias(); + boolean inProjection = source.getColumnProjection(columnId, getArguments(), true) != null; ColumnProjection outerProjection; Set innerProjections; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLMetricProjection.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLMetricProjection.java index 79c9bd9f42..f985e01809 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLMetricProjection.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLMetricProjection.java @@ -165,7 +165,8 @@ public Pair> nest(Queryable source, + dialect.getEndQuote() + "?", "{{\\$" + "$1" + "}}"); - boolean inProjection = source.getColumnProjection(name, arguments, true) != null; + String columnId = source.isRoot() ? getName() : getAlias(); + boolean inProjection = source.getColumnProjection(columnId, arguments, true) != null; ColumnProjection outerProjection = SQLMetricProjection.builder() .projected(inProjection) diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLTimeDimensionProjection.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLTimeDimensionProjection.java index 97ea57da87..ff46d1c6d7 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLTimeDimensionProjection.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLTimeDimensionProjection.java @@ -104,7 +104,8 @@ public Pair> nest(Queryable source, boolean requiresJoin = SQLColumnProjection.requiresJoin(references); - boolean inProjection = source.getColumnProjection(getName(), getArguments(), true) != null; + String columnId = source.isRoot() ? getName() : getAlias(); + boolean inProjection = source.getColumnProjection(columnId, getArguments(), true) != null; ColumnProjection outerProjection; Set innerProjections; diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Day.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Day.java index 098f8df01d..e75f6a085c 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Day.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Day.java @@ -11,6 +11,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -36,6 +37,10 @@ public Day deserialize(Object val) { if (val instanceof Date) { return new Day((Date) val); } + if (val instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) val; + return new Day(offsetDateTime.toLocalDateTime()); + } LocalDate localDate = LocalDate.parse(val.toString(), DateTimeFormatter.ISO_LOCAL_DATE); LocalDateTime localDateTime = localDate.atTime(0, 0); return new Day(localDateTime); diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Hour.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Hour.java index 65f9daa018..ef3f846a95 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Hour.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Hour.java @@ -10,6 +10,7 @@ import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -38,6 +39,10 @@ public Hour deserialize(Object val) { if (val instanceof Date) { return new Hour((Date) val); } + if (val instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) val; + return new Hour(offsetDateTime.toLocalDateTime()); + } return new Hour(LocalDateTime.parse(val.toString(), FORMATTER)); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/ISOWeek.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/ISOWeek.java index 50f2fceaa5..7b12fa434a 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/ISOWeek.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/ISOWeek.java @@ -12,6 +12,7 @@ import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -35,8 +36,14 @@ public ISOWeek deserialize(Object val) { if (val instanceof Date) { date = LocalDateTime.ofInstant(((Date) val).toInstant(), ZoneOffset.systemDefault()); } else { - LocalDate localDate = LocalDate.parse(val.toString(), DateTimeFormatter.ISO_LOCAL_DATE); - date = localDate.atTime(0, 0); + LocalDate localDate; + if (val instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) val; + date = offsetDateTime.toLocalDate().atTime(0, 0); + } else { + localDate = LocalDate.parse(val.toString(), DateTimeFormatter.ISO_LOCAL_DATE); + date = localDate.atTime(0, 0); + } } if (date.getDayOfWeek() != DayOfWeek.MONDAY) { diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Minute.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Minute.java index 8c7e7528ff..ae7e052424 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Minute.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Minute.java @@ -10,6 +10,7 @@ import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -38,6 +39,10 @@ public Minute deserialize(Object val) { if (val instanceof Date) { return new Minute((Date) val); } + if (val instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) val; + return new Minute(offsetDateTime.toLocalDateTime()); + } return new Minute(LocalDateTime.parse(val.toString(), FORMATTER)); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Month.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Month.java index 4d405ff351..b4d0bbc0b1 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Month.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Month.java @@ -10,6 +10,7 @@ import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.YearMonth; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; @@ -39,6 +40,10 @@ public Month deserialize(Object val) { if (val instanceof Date) { return new Month((Date) val); } + if (val instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) val; + return new Month(offsetDateTime.toLocalDateTime()); + } YearMonth yearMonth = YearMonth.parse(val.toString(), FORMATTER); LocalDateTime localDateTime = LocalDateTime.of(yearMonth.getYear(), yearMonth.getMonth(), 1, 0, 0); return new Month(localDateTime); diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Quarter.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Quarter.java index fda000a1d8..1b763f7f8c 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Quarter.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Quarter.java @@ -10,6 +10,7 @@ import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.YearMonth; import java.time.ZoneId; import java.time.ZoneOffset; @@ -37,7 +38,14 @@ public Quarter deserialize(Object val) { if (val instanceof Date) { date = LocalDateTime.ofInstant(((Date) val).toInstant(), ZoneOffset.systemDefault()); } else { - YearMonth yearMonth = YearMonth.parse(val.toString(), FORMATTER); + YearMonth yearMonth; + if (val instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) val; + yearMonth = YearMonth.from(offsetDateTime); + } else { + yearMonth = YearMonth.parse(val.toString(), FORMATTER); + } + date = LocalDateTime.of(yearMonth.getYear(), yearMonth.getMonth(), 1, 0, 0); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Second.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Second.java index 491239d6b0..4b01b35c8e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Second.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Second.java @@ -10,6 +10,7 @@ import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -38,6 +39,10 @@ public Second deserialize(Object val) { if (val instanceof Date) { return new Second((Date) val); } + if (val instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) val; + return new Second(offsetDateTime.toLocalDateTime()); + } return new Second(LocalDateTime.parse(val.toString(), FORMATTER)); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Time.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Time.java index c7ed7263c7..00784e372f 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Time.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Time.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import lombok.Setter; +import java.io.Serializable; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; @@ -70,7 +71,7 @@ public int hashCode() { } @FunctionalInterface - public interface Serializer { + public interface Serializer extends Serializable { String format(Time time); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Week.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Week.java index d203140e59..c6bfd3f164 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Week.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Week.java @@ -12,6 +12,7 @@ import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -35,10 +36,15 @@ public Week deserialize(Object val) { if (val instanceof Date) { date = LocalDateTime.ofInstant(((Date) val).toInstant(), ZoneOffset.systemDefault()); } else { - LocalDate localDate = LocalDate.parse(val.toString(), DateTimeFormatter.ISO_LOCAL_DATE); - date = localDate.atTime(0, 0); + LocalDate localDate; + if (val instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) val; + date = offsetDateTime.toLocalDate().atTime(0, 0); + } else { + localDate = LocalDate.parse(val.toString(), DateTimeFormatter.ISO_LOCAL_DATE); + date = localDate.atTime(0, 0); + } } - if (date.getDayOfWeek() != DayOfWeek.SUNDAY) { throw new IllegalArgumentException("Date string not a Sunday"); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Year.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Year.java index 9b6fdccdc6..c3e9ddc13b 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Year.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/timegrains/Year.java @@ -11,6 +11,7 @@ import java.time.LocalDateTime; import java.time.Month; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.Date; @@ -39,6 +40,11 @@ public Year deserialize(Object val) { if (val instanceof Date) { return new Year((Date) val); } + if (val instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) val; + return new Year(offsetDateTime.toLocalDateTime()); + } + java.time.Year year = java.time.Year.parse(val.toString(), FORMATTER); LocalDateTime localDateTime = LocalDateTime.of(year.getValue(), Month.of(1), 1, 0, 0); return new Year(localDateTime); diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/validator/TableArgumentValidator.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/validator/TableArgumentValidator.java index 32a35c2876..f3f87edc9e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/validator/TableArgumentValidator.java +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/validator/TableArgumentValidator.java @@ -12,6 +12,7 @@ import com.yahoo.elide.core.type.Type; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; import com.yahoo.elide.datastores.aggregation.metadata.models.ArgumentDefinition; +import com.yahoo.elide.datastores.aggregation.query.Query; import com.yahoo.elide.datastores.aggregation.queryengines.sql.expression.ExpressionParser; import com.yahoo.elide.datastores.aggregation.queryengines.sql.expression.TableArgReference; import com.yahoo.elide.datastores.aggregation.queryengines.sql.metadata.SQLTable; @@ -63,8 +64,15 @@ public void validate() { private void verifyTableArgsInTableSql() { Type tableClass = dictionary.getEntityClass(table.getName(), table.getVersion()); + Query mockQuery = Query.builder() + .source(table) + .dimensionProjections(table.getDimensionProjections()) + .metricProjections(table.getMetricProjections()) + .timeDimensionProjections(table.getTimeDimensionProjections()) + .build(); + if (hasSql(tableClass)) { - String selectSql = resolveTableOrSubselect(dictionary, tableClass); + String selectSql = resolveTableOrSubselect(dictionary, tableClass, mockQuery); verifyTableArgsExists(selectSql, "in table's sql."); } } @@ -136,7 +144,18 @@ public static void verifyValues(ArgumentDefinition argument, String errorMsgPref } public static void verifyDefaultValue(ArgumentDefinition argument, String errorMsgPrefix) { - String defaultValue = argument.getDefaultValue().toString(); + Object value = argument.getDefaultValue(); + + /* + * Arguments must have default values or else we won't be able to evaluate the correctness of their expressions + * at build time. + */ + if (value == null) { + throw new IllegalStateException(String.format("Argument '%s' is missing a default value", + argument.getName())); + } + + String defaultValue = value.toString(); verifyValue(argument, defaultValue, errorMsgPrefix + "Default "); } diff --git a/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/validator/TemplateConfigValidator.java b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/validator/TemplateConfigValidator.java new file mode 100644 index 0000000000..d509d705f5 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/main/java/com/yahoo/elide/datastores/aggregation/validator/TemplateConfigValidator.java @@ -0,0 +1,85 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.aggregation.validator; + +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.datastores.aggregation.DefaultQueryValidator; +import com.yahoo.elide.datastores.aggregation.metadata.FormulaValidator; +import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.SQLQueryEngine; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.metadata.SQLTable; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; +import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; +import com.yahoo.elide.modelconfig.validator.Validator; + +import java.util.HashSet; +import java.util.Map; + +/** + * Delegates to DynamicConfigValidator for HJSON syntax and JSON schema validation. Then builds + * table metadata to perform template validation. + */ +public class TemplateConfigValidator implements Validator { + + private final ClassScanner scanner; + private final String configRoot; + + public TemplateConfigValidator( + ClassScanner scanner, + String configRoot + ) { + this.scanner = scanner; + this.configRoot = configRoot; + } + + //Rebuilds the MetaDataStore for each validation so that we can validate templates. + MetaDataStore rebuildMetaDataStore(Map resourceMap) { + DynamicConfigValidator validator = new DynamicConfigValidator(scanner, + configRoot); + + validator.validate(resourceMap); + + MetaDataStore metaDataStore = new MetaDataStore(scanner, validator.getTables(), + validator.getNamespaceConfigurations(), false); + + //Populates the metadata store with SQL tables. + new SQLQueryEngine(metaDataStore, (unused) -> null, new HashSet<>(), + new DefaultQueryPlanMerger(metaDataStore), + new DefaultQueryValidator(metaDataStore.getMetadataDictionary())); + + return metaDataStore; + } + + @Override + public void validate(Map resourceMap) { + MetaDataStore metaDataStore = rebuildMetaDataStore(resourceMap); + + metaDataStore.getTables().forEach(table -> { + SQLTable sqlTable = (SQLTable) table; + + checkForCycles(sqlTable, metaDataStore); + + TableArgumentValidator tableArgValidator = new TableArgumentValidator(metaDataStore, sqlTable); + tableArgValidator.validate(); + + sqlTable.getAllColumns().forEach(column -> { + ColumnArgumentValidator colArgValidator = new ColumnArgumentValidator(metaDataStore, sqlTable, column); + colArgValidator.validate(); + }); + }); + } + + /** + * Verify that there is no reference loop for given {@link SQLTable}. + * @param sqlTable Queryable to validate. + */ + private void checkForCycles(SQLTable sqlTable, MetaDataStore metaDataStore) { + FormulaValidator formulaValidator = new FormulaValidator(metaDataStore); + sqlTable.getColumnProjections().forEach(column -> formulaValidator.parse(sqlTable, column)); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/AggregationDataStoreTransactionTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/AggregationDataStoreTransactionTest.java index 20059aa6fc..2ef5f39150 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/AggregationDataStoreTransactionTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/AggregationDataStoreTransactionTest.java @@ -48,6 +48,7 @@ import com.yahoo.elide.datastores.aggregation.queryengines.sql.metadata.SQLTable; import com.yahoo.elide.datastores.aggregation.queryengines.sql.query.NativeQuery; import com.yahoo.elide.datastores.aggregation.queryengines.sql.query.SQLMetricProjection; +import com.google.common.collect.Lists; import example.PlayerStats; import example.PlayerStatsWithRequiredFilter; import org.junit.jupiter.api.BeforeAll; @@ -127,7 +128,7 @@ public void testRequiredTableFilterArguments() throws Exception { SQLTable table = new SQLTable(new Namespace(DEFAULT_NAMESPACE), tableType, dictionary); - RSQLFilterDialect filterDialect = new RSQLFilterDialect(dictionary); + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); FilterExpression where = filterDialect.parse(tableType, new HashSet<>(), "recordedDate>=2019-07-12T00:00Z;recordedDate<2030-07-12T00:00Z", NO_VERSION); @@ -174,7 +175,7 @@ public void testRequiredColumnFilterArguments() throws Exception { SQLTable table = new SQLTable(new Namespace(DEFAULT_NAMESPACE), tableType, dictionary); - RSQLFilterDialect filterDialect = new RSQLFilterDialect(dictionary); + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); FilterExpression where = filterDialect.parse(tableType, new HashSet<>(), "recordedDate>2019-07-12T00:00Z", NO_VERSION); @@ -207,7 +208,7 @@ public void loadObjectsPopulatesCache() { new MyAggregationDataStoreTransaction(queryEngine, cache, queryLogger); EntityProjection entityProjection = EntityProjection.builder().type(PlayerStats.class).build(); - assertEquals(DATA, transaction.loadObjects(entityProjection, scope)); + assertEquals(DATA, Lists.newArrayList(transaction.loadObjects(entityProjection, scope))); String cacheKey = "foo;" + queryKey; Mockito.verify(cache).get(cacheKey); @@ -238,7 +239,7 @@ public void loadObjectsUsesCache() { new MyAggregationDataStoreTransaction(queryEngine, cache, queryLogger); EntityProjection entityProjection = EntityProjection.builder().type(PlayerStats.class).build(); - assertEquals(DATA, transaction.loadObjects(entityProjection, scope)); + assertEquals(DATA, Lists.newArrayList(transaction.loadObjects(entityProjection, scope))); Mockito.verify(queryEngine, never()).executeQuery(any(), any()); Mockito.verify(cache).get(cacheKey); @@ -270,7 +271,7 @@ public void loadObjectsPassesPagination() { EntityProjection entityProjection = EntityProjection.builder() .type(PlayerStats.class).pagination(pagination).build(); - assertEquals(DATA, transaction.loadObjects(entityProjection, scope)); + assertEquals(DATA, Lists.newArrayList(transaction.loadObjects(entityProjection, scope))); assertEquals(314L, entityProjection.getPagination().getPageTotals()); String cacheKey = "foo;" + queryKey; @@ -333,7 +334,7 @@ public void loadObjectsBypassCache() { new MyAggregationDataStoreTransaction(queryEngine, cache, queryLogger); EntityProjection entityProjection = EntityProjection.builder().type(PlayerStats.class).build(); - assertEquals(DATA, transaction.loadObjects(entityProjection, scope)); + assertEquals(DATA, Lists.newArrayList(transaction.loadObjects(entityProjection, scope))); Mockito.verify(queryEngine, never()).getTableVersion(any(), any()); Mockito.verifyNoInteractions(cache); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/DefaultQueryValidatorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/DefaultQueryValidatorTest.java index 05101d31b6..80d3ca9c36 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/DefaultQueryValidatorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/DefaultQueryValidatorTest.java @@ -203,7 +203,27 @@ public void testInvalidColumnValueInFilter() throws ParseException { .havingFilter(havingFilter) .build(); - validateQuery(query, "Invalid operation: Column 'countryIsoCode' values must match one of these values: [HK, USA]"); + validateQuery(query, "Invalid operation: Column 'countryIsoCode' values must match one of these values: [HKG, USA]"); + } + + @Test + public void testValidRegexColumnValueInFilter() throws ParseException { + FilterExpression originalFilter = filterParser.parseFilterExpression("countryIsoCode=in=('*H'),lowScore<45", + playerStatsType, false); + SplitFilterExpressionVisitor visitor = new SplitFilterExpressionVisitor(playerStatsTable); + FilterConstraints constraints = originalFilter.accept(visitor); + FilterExpression whereFilter = constraints.getWhereExpression(); + FilterExpression havingFilter = constraints.getHavingExpression(); + + Query query = Query.builder() + .source(playerStatsTable) + .metricProjection(playerStatsTable.getMetricProjection("lowScore")) + .dimensionProjection(playerStatsTable.getDimensionProjection("countryIsoCode")) + .whereFilter(whereFilter) + .havingFilter(havingFilter) + .build(); + + validateQueryDoesNotThrow(query); } @Test @@ -265,7 +285,7 @@ public void testHavingFilterMatchesProjection() throws ParseException { } @Test - public void testMissingRequiredParameterInProjection() throws ParseException { + public void testMissingRequiredParameterInProjection() { SQLTable source = (SQLTable) metaDataStore.getTable("playerStatsView", NO_VERSION); Query query = Query.builder() diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/EntityProjectionTranslatorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/EntityProjectionTranslatorTest.java index db3dd39c3a..97fe529e76 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/EntityProjectionTranslatorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/EntityProjectionTranslatorTest.java @@ -6,7 +6,6 @@ package com.yahoo.elide.datastores.aggregation; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.when; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.filter.dialect.ParseException; import com.yahoo.elide.core.filter.expression.FilterExpression; @@ -23,13 +22,11 @@ import com.yahoo.elide.datastores.aggregation.query.TimeDimensionProjection; import example.PlayerStats; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; @@ -59,11 +56,6 @@ public static void init() { SQLUnitTest.init(); } - @BeforeEach - public void setUp() { - when(scope.getDictionary()).thenReturn(dictionary); - } - @Test public void testBasicTranslation() { EntityProjectionTranslator translator = new EntityProjectionTranslator( @@ -178,7 +170,7 @@ public void testHavingClauseMetricsMissingFromProjection() throws ParseException } @Test - public void testQueryArguments() throws IOException { + public void testQueryArguments() { EntityProjectionTranslator translator = new EntityProjectionTranslator( engine, playerStatsTable, diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/annotation/dimensionformula/DimensionFormulaTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/annotation/dimensionformula/DimensionFormulaTest.java index f206c3ebe2..00fb0c57e0 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/annotation/dimensionformula/DimensionFormulaTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/annotation/dimensionformula/DimensionFormulaTest.java @@ -41,7 +41,7 @@ public void testReferenceLoop() { IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> new SQLQueryEngine(metaDataStore, DUMMY_CONNECTION)); + () -> new SQLQueryEngine(metaDataStore, (unused) -> DUMMY_CONNECTION)); assertTrue(exception.getMessage().startsWith("Formula reference loop found:")); } @@ -53,7 +53,7 @@ public void testCrossClassReferenceLoop() { IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> new SQLQueryEngine(metaDataStore, DUMMY_CONNECTION)); + () -> new SQLQueryEngine(metaDataStore, (unused) -> DUMMY_CONNECTION)); String exception1 = "Formula reference loop found: loopCountryA.inUsa->loopCountryB.inUsa->loopCountryA.inUsa"; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/annotation/metricformula/MetricFormulaTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/annotation/metricformula/MetricFormulaTest.java index e4ce7a1dea..6d384ea323 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/annotation/metricformula/MetricFormulaTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/annotation/metricformula/MetricFormulaTest.java @@ -23,7 +23,7 @@ public void testReferenceLoop() { IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, - () -> new SQLQueryEngine(metaDataStore, DUMMY_CONNECTION)); + () -> new SQLQueryEngine(metaDataStore, (unused) -> DUMMY_CONNECTION)); assertTrue(exception.getMessage().startsWith("Formula reference loop found:")); } } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/cache/QueryKeyExtractorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/cache/QueryKeyExtractorTest.java index 6ae0da9286..b22fa28a96 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/cache/QueryKeyExtractorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/cache/QueryKeyExtractorTest.java @@ -14,15 +14,23 @@ import com.yahoo.elide.core.request.Sorting; import com.yahoo.elide.core.sort.SortingImpl; import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.datastores.aggregation.dynamic.NamespacePackage; +import com.yahoo.elide.datastores.aggregation.dynamic.TableType; import com.yahoo.elide.datastores.aggregation.framework.SQLUnitTest; import com.yahoo.elide.datastores.aggregation.metadata.models.Namespace; import com.yahoo.elide.datastores.aggregation.query.ImmutablePagination; import com.yahoo.elide.datastores.aggregation.query.Query; import com.yahoo.elide.datastores.aggregation.queryengines.sql.metadata.SQLTable; +import com.yahoo.elide.modelconfig.model.Dimension; +import com.yahoo.elide.modelconfig.model.NamespaceConfig; +import com.yahoo.elide.modelconfig.model.Table; +import com.yahoo.elide.modelconfig.model.Type; + import example.PlayerStats; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import java.util.Collections; import java.util.Map; import java.util.TreeMap; @@ -55,7 +63,7 @@ public void testMinimalQuery() { @Test public void testFullQuery() throws Exception { - RSQLFilterDialect filterParser = new RSQLFilterDialect(dictionary); + RSQLFilterDialect filterParser = RSQLFilterDialect.builder().dictionary(dictionary).build(); Map sortMap = new TreeMap<>(); sortMap.put("playerName", Sorting.SortOrder.asc); Query query = Query.builder() @@ -81,6 +89,108 @@ public void testFullQuery() throws Exception { QueryKeyExtractor.extractKey(query)); } + @Test + public void testDuplicateFullQuery() throws Exception { + // Build 1st Table + NamespaceConfig testNamespace = NamespaceConfig.builder() + .name("namespace1") + .build(); + NamespacePackage testNamespacePackage = new NamespacePackage(testNamespace); + + Table testTable = Table.builder() + .cardinality("medium") + .description("A test table") + .friendlyName("foo") + .table("table1") + .name("Table") + .schema("db1") + .category("category1") + .readAccess("Admin") + .dbConnectionName("dbConn") + .isFact(true) + .filterTemplate("a==b") + .namespace("namespace1") + .dimension(Dimension.builder() + .name("dim1") + .definition("{{dim1}}") + .type(Type.BOOLEAN) + .values(Collections.EMPTY_SET) + .tags(Collections.EMPTY_SET) + .build()) + .build(); + TableType testType = new TableType(testTable, testNamespacePackage); + dictionary.bindEntity(testType); + + SQLTable testSqlTable = new SQLTable(new Namespace(testNamespacePackage) , testType, dictionary); + + // Build 2nd Table + NamespaceConfig anotherTestNamespace = NamespaceConfig.builder() + .name("namespace2") + .build(); + NamespacePackage anotherTestNamespacePackage = new NamespacePackage(anotherTestNamespace); + + Table anotherTestTable = Table.builder() // Exactly same as testTable but different namespace + .cardinality("medium") + .description("A test table") + .friendlyName("foo") + .table("table1") + .name("Table") + .schema("db1") + .category("category1") + .readAccess("Admin") + .dbConnectionName("dbConn") + .isFact(true) + .filterTemplate("a==b") + .namespace("namespace2") + .dimension(Dimension.builder() + .name("dim1") + .definition("{{dim1}}") + .type(Type.BOOLEAN) + .values(Collections.EMPTY_SET) + .tags(Collections.EMPTY_SET) + .build()) + .build(); + TableType anotherTestType = new TableType(anotherTestTable, anotherTestNamespacePackage); + dictionary.bindEntity(anotherTestType); + + SQLTable anotherTestSqlTable = new SQLTable(new Namespace(anotherTestNamespacePackage) , anotherTestType, dictionary); + + // Build Query and Test + Query query = Query.builder() + .source(testSqlTable) + .dimensionProjection(testSqlTable.getDimensionProjection("dim1")) + .pagination(new ImmutablePagination(0, 2, false, true)) + .build(); + + assertEquals("namespace1_Table;" // table name + + "{}" // metrics + + "{dim1;{}}" // Group by + + "{}" // time dimensions + + ";" // where + + ";" // having + + ";" // sort + + "{0;2;1;}", // pagination + QueryKeyExtractor.extractKey(query)); + + Query anotherQuery = Query.builder() + .source(anotherTestSqlTable) + .dimensionProjection(anotherTestSqlTable.getDimensionProjection("dim1")) + .pagination(new ImmutablePagination(0, 2, false, true)) + .build(); + + assertEquals("namespace2_Table;" // table name + + "{}" // metrics + + "{dim1;{}}" // Group by + + "{}" // time dimensions + + ";" // where + + ";" // having + + ";" // sort + + "{0;2;1;}", // pagination + QueryKeyExtractor.extractKey(anotherQuery)); + + assertNotEquals(QueryKeyExtractor.extractKey(anotherQuery), QueryKeyExtractor.extractKey(query)); + } + @Test public void testColumnsOrdered() { assertNotEquals( diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/cache/RedisCacheTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/cache/RedisCacheTest.java new file mode 100644 index 0000000000..6a5ba13a54 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/cache/RedisCacheTest.java @@ -0,0 +1,101 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.datastores.aggregation.cache; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.yahoo.elide.datastores.aggregation.query.QueryResult; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import redis.clients.jedis.JedisPooled; +import redis.clients.jedis.exceptions.JedisConnectionException; +import redis.embedded.RedisServer; + +import java.io.IOException; +import java.util.Collections; + +/** + * Test cases for RedisCache. + */ +public class RedisCacheTest { + private static final String HOST = "localhost"; + private static final int PORT = 6379; + private static final int EXPIRATION_MINUTES = 2; + private JedisPooled jedisPool; + private RedisServer redisServer; + RedisCache cache; + + @BeforeEach + public void setup() throws IOException { + redisServer = new RedisServer(PORT); + redisServer.start(); + jedisPool = new JedisPooled(HOST, PORT); + cache = new RedisCache(jedisPool, EXPIRATION_MINUTES); + } + + @AfterEach + public void destroy() throws IOException { + redisServer.stop(); + } + + @Test + public void testGetNonExistent() { + String key = "example_NonExist;{highScore;{}}{}{};;;;"; + assertEquals(null, cache.get(key)); + } + + @Test + public void testPutResults() { + String key = "example_PlayerStats;{highScore;{}}{}{};;;;"; + Iterable data = Collections.singletonList("xyzzy"); + QueryResult queryResult = QueryResult.builder().data(data).build(); + + cache.put(key, queryResult); + + //retrive results and verify they match original. + assertEquals(queryResult, cache.get(key)); + assertEquals("xyzzy", cache.get(key).getData().iterator().next()); + } + + // Redis server does not exist. + @Test + public void testPutResultsFail() throws IOException { + destroy(); + String key = "example_PlayerStats;{highScore;{}}{}{};;;;"; + Iterable data = Collections.singletonList("xyzzy"); + QueryResult queryResult = QueryResult.builder().data(data).build(); + assertThrows(JedisConnectionException.class, () -> + cache.put(key, queryResult) + ); + } + + @Test + public void testGetResultsMulti() { + String key = "example_PlayerStats;{highScore;{}}{}{};;;;"; + Iterable data = Collections.singletonList("xyzzy"); + QueryResult queryResult = QueryResult.builder().data(data).build(); + + cache.put(key, queryResult); + + //retrive results and verify they match original. + assertEquals(queryResult, cache.get(key)); + + //retrive results again and verify they match original. + assertEquals(queryResult, cache.get(key)); + + String key1 = "example_PlayerStats1;{highScore;{}}{}{};;;;"; + Iterable data1 = Collections.singletonList("xyzz"); + QueryResult queryResult1 = QueryResult.builder().data(data).build(); + + cache.put(key1, queryResult1); + + //retrive results and verify they match original. + assertEquals(queryResult1, cache.get(key1)); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/checks/VideoGameFilterCheck.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/checks/VideoGameFilterCheck.java index ce9a621f30..f9867a03e6 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/checks/VideoGameFilterCheck.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/checks/VideoGameFilterCheck.java @@ -15,7 +15,7 @@ import example.VideoGame; /** - * Filter Expression Check for video game + * Filter Expression Check for video game. */ @SecurityCheck(VideoGameFilterCheck.NAME_FILTER) public class VideoGameFilterCheck extends FilterExpressionCheck { diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/core/JoinPathTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/core/JoinPathTest.java index fed7fbe521..460f68176a 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/core/JoinPathTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/core/JoinPathTest.java @@ -54,7 +54,7 @@ public static void init() { DataSource mockDataSource = mock(DataSource.class); //The query engine populates the metadata store with actual tables. - new SQLQueryEngine(store, new ConnectionDetails(mockDataSource, + new SQLQueryEngine(store, (unused) -> new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/dynamic/NamespacePackageTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/dynamic/NamespacePackageTest.java index ce000733ed..0b4a818739 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/dynamic/NamespacePackageTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/dynamic/NamespacePackageTest.java @@ -25,14 +25,14 @@ void testAnnotations() throws Exception { NamespacePackage namespace = new NamespacePackage(testNamespace); - ReadPermission readPermission = (ReadPermission) namespace.getDeclaredAnnotation(ReadPermission.class); + ReadPermission readPermission = namespace.getDeclaredAnnotation(ReadPermission.class); assertEquals("Prefab.Role.All", readPermission.expression()); - Include meta = (Include) namespace.getDeclaredAnnotation(Include.class); + Include meta = namespace.getDeclaredAnnotation(Include.class); assertEquals("A test Namespace", meta.description()); assertNull(meta.friendlyName()); - ApiVersion apiVersion = (ApiVersion) namespace.getDeclaredAnnotation(ApiVersion.class); + ApiVersion apiVersion = namespace.getDeclaredAnnotation(ApiVersion.class); assertEquals("", apiVersion.version()); } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/dynamic/TableTypeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/dynamic/TableTypeTest.java index c488ad8fbb..d140177e78 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/dynamic/TableTypeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/dynamic/TableTypeTest.java @@ -10,10 +10,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.yahoo.elide.annotation.Exclude; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.ReadPermission; import com.yahoo.elide.core.type.Field; @@ -41,6 +39,8 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; import javax.persistence.Id; public class TableTypeTest { @@ -123,10 +123,12 @@ void testHiddenTableAnnotations() throws Exception { TableType testType = new TableType(testTable); - Exclude exclude = (Exclude) testType.getAnnotation(Exclude.class); - assertNotNull(exclude); Include include = (Include) testType.getAnnotation(Include.class); - assertNull(include); + assertNotNull(include); + + TableMeta tableMeta = (TableMeta) testType.getAnnotation(TableMeta.class); + assertNotNull(tableMeta); + assertTrue(tableMeta.isHidden()); } @Test @@ -416,10 +418,9 @@ void testHiddenDimension() throws Exception { Field field = testType.getDeclaredField("dim1"); assertNotNull(field); - Exclude exclude = field.getAnnotation(Exclude.class); - assertNotNull(exclude); - Include include = field.getAnnotation(Include.class); - assertNull(include); + ColumnMeta columnMeta = field.getAnnotation(ColumnMeta.class); + assertNotNull(columnMeta); + assertTrue(columnMeta.isHidden()); } @Test @@ -439,10 +440,9 @@ void testHiddenMeasure() throws Exception { Field field = testType.getDeclaredField("measure1"); assertNotNull(field); - Exclude exclude = field.getAnnotation(Exclude.class); - assertNotNull(exclude); - Include include = field.getAnnotation(Include.class); - assertNull(include); + ColumnMeta columnMeta = field.getAnnotation(ColumnMeta.class); + assertNotNull(columnMeta); + assertTrue(columnMeta.isHidden()); } @Test @@ -464,4 +464,26 @@ void testInvalidResolver() throws Exception { assertThrows(IllegalStateException.class, () -> metricFormula.maker()); } + + @Test + void testEnumeratedDimension() throws Exception { + Table testTable = Table.builder() + .table("table1") + .name("Table") + .dimension(Dimension.builder() + .name("dim1") + .type("ENUM_ORDINAL") + .values(Set.of("A", "B", "C")) + .build()) + .build(); + + TableType testType = new TableType(testTable); + + Field field = testType.getDeclaredField("dim1"); + assertNotNull(field); + + Enumerated enumerated = field.getAnnotation(Enumerated.class); + assertNotNull(enumerated); + assertEquals(EnumType.ORDINAL, enumerated.value()); + } } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/filter/visitor/MatchesTemplateVisitorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/filter/visitor/MatchesTemplateVisitorTest.java index e0facf30fc..b39d8da82b 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/filter/visitor/MatchesTemplateVisitorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/filter/visitor/MatchesTemplateVisitorTest.java @@ -9,12 +9,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.core.dictionary.ArgumentType; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.request.Argument; +import com.yahoo.elide.core.request.Attribute; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; import example.Player; import example.PlayerStats; import org.junit.jupiter.api.BeforeEach; @@ -22,6 +25,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.Set; public class MatchesTemplateVisitorTest { private RSQLFilterDialect dialect; @@ -32,7 +36,13 @@ public void setup() { EntityDictionary dictionary = EntityDictionary.builder().build(); dictionary.bindEntity(PlayerStats.class); dictionary.bindEntity(Player.class); - dialect = new RSQLFilterDialect(dictionary); + + dictionary.addArgumentToAttribute( + dictionary.getEntityClass("playerStats", EntityDictionary.NO_VERSION), + "recordedDate", + new ArgumentType("grain", ClassType.STRING_TYPE, TimeGrain.DAY)); + + dialect = RSQLFilterDialect.builder().dictionary(dictionary).addDefaultArguments(false).build(); } @Test @@ -55,6 +65,32 @@ public void predicateMatchTest() throws Exception { assertEquals(extractedArgs.get("foo"), expected); } + @Test + public void predicateWithAliasMatchesTest() throws Exception { + FilterExpression clientExpression = dialect.parseFilterExpression("highScore==123", + playerStatsType, true); + + Attribute attribute = Attribute.builder() + .type(ClassType.of(Long.class)) + .name("highScore") + .alias("myScore") + .build(); + + FilterExpression templateExpression = dialect.parseFilterExpression("myScore=={{foo}}", + playerStatsType, false, true, Set.of(attribute)); + + Map extractedArgs = new HashMap<>(); + Argument expected = Argument.builder() + .name("foo") + .value(123L) + .build(); + + assertTrue(MatchesTemplateVisitor.isValid(templateExpression, clientExpression, extractedArgs)); + + assertEquals(1, extractedArgs.size()); + assertEquals(extractedArgs.get("foo"), expected); + } + @Test public void predicateMatchWithoutTemplateTest() throws Exception { FilterExpression clientExpression = dialect.parseFilterExpression("highScore==123", @@ -148,11 +184,11 @@ public void conjunctionDoesNotContainTest() throws Exception { } @Test - public void parameterizedFilterDoesNotMatch() throws Exception { + public void parameterizedFilterArgumentsDoNotMatch() throws Exception { FilterExpression clientExpression = dialect.parseFilterExpression("recordedDate[grain:day]=='2020-01-01'", playerStatsType, true); - FilterExpression templateExpression = dialect.parseFilterExpression("recordedDate=={{day}}", + FilterExpression templateExpression = dialect.parseFilterExpression("recordedDate[grain:month]=={{day}}", playerStatsType, false, true); Map extractedArgs = new HashMap<>(); @@ -160,6 +196,19 @@ public void parameterizedFilterDoesNotMatch() throws Exception { assertEquals(0, extractedArgs.size()); } + @Test + public void parameterizedFilterArgumentsIgnored() throws Exception { + FilterExpression clientExpression = dialect.parseFilterExpression("recordedDate[grain:day]=='2020-01-01'", + playerStatsType, true); + + FilterExpression templateExpression = dialect.parseFilterExpression("recordedDate=={{day}}", + playerStatsType, false, true); + + Map extractedArgs = new HashMap<>(); + assertTrue(MatchesTemplateVisitor.isValid(templateExpression, clientExpression, extractedArgs)); + assertEquals(1, extractedArgs.size()); + } + @Test public void parameterizedFilterMatches() throws Exception { FilterExpression clientExpression = dialect.parseFilterExpression("recordedDate[grain:day]=='2020-01-01'", diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/AggregationDataStoreTestHarness.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/AggregationDataStoreTestHarness.java index a3a5c292e2..037526d10d 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/AggregationDataStoreTestHarness.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/AggregationDataStoreTestHarness.java @@ -5,7 +5,6 @@ */ package com.yahoo.elide.datastores.aggregation.framework; -import com.yahoo.elide.core.datastore.DataStore; import com.yahoo.elide.core.datastore.test.DataStoreTestHarness; import com.yahoo.elide.core.utils.ClassScanner; import com.yahoo.elide.core.utils.DefaultClassScanner; @@ -13,16 +12,19 @@ import com.yahoo.elide.datastores.aggregation.DefaultQueryValidator; import com.yahoo.elide.datastores.aggregation.core.Slf4jQueryLogger; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; import com.yahoo.elide.datastores.aggregation.queryengines.sql.SQLQueryEngine; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.query.AggregateBeforeJoinOptimizer; import com.yahoo.elide.datastores.jpa.JpaDataStore; import com.yahoo.elide.datastores.jpa.transaction.NonJtaTransaction; -import com.yahoo.elide.datastores.multiplex.MultiplexManager; import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; import org.hibernate.Session; import lombok.AllArgsConstructor; +import lombok.Getter; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.Map; @@ -32,7 +34,8 @@ import javax.sql.DataSource; @AllArgsConstructor -public class AggregationDataStoreTestHarness implements DataStoreTestHarness { +@Getter +public abstract class AggregationDataStoreTestHarness implements DataStoreTestHarness { private EntityManagerFactory entityManagerFactory; private ConnectionDetails defaultConnectionDetails; private Map connectionDetailsMap; @@ -47,11 +50,16 @@ public AggregationDataStoreTestHarness(EntityManagerFactory entityManagerFactory this(entityManagerFactory, defaultConnectionDetails, Collections.emptyMap(), null); } - @Override - public DataStore getDataStore() { + protected JpaDataStore createJPADataStore() { + Consumer txCancel = em -> em.unwrap(Session.class).cancelQuery(); - AggregationDataStore.AggregationDataStoreBuilder aggregationDataStoreBuilder = AggregationDataStore.builder(); + return new JpaDataStore( + () -> entityManagerFactory.createEntityManager(), + em -> new NonJtaTransaction(em, txCancel) + ); + } + protected MetaDataStore createMetaDataStore() { ClassScanner scanner = DefaultClassScanner.getInstance(); MetaDataStore metaDataStore; @@ -59,26 +67,26 @@ public DataStore getDataStore() { metaDataStore = new MetaDataStore(scanner, validator.getElideTableConfig().getTables(), validator.getElideNamespaceConfig().getNamespaceconfigs(), true); - - aggregationDataStoreBuilder.dynamicCompiledClasses(metaDataStore.getDynamicTypes()); } else { metaDataStore = new MetaDataStore(scanner, true); } - AggregationDataStore aggregationDataStore = aggregationDataStoreBuilder - .queryEngine(new SQLQueryEngine(metaDataStore, defaultConnectionDetails, connectionDetailsMap, - new HashSet<>(), new DefaultQueryValidator(metaDataStore.getMetadataDictionary()))) - .queryLogger(new Slf4jQueryLogger()) - .build(); - - Consumer txCancel = em -> em.unwrap(Session.class).cancelQuery(); + return metaDataStore; + } - DataStore jpaStore = new JpaDataStore( - () -> entityManagerFactory.createEntityManager(), - em -> new NonJtaTransaction(em, txCancel) - ); + protected AggregationDataStore.AggregationDataStoreBuilder createAggregationDataStoreBuilder(MetaDataStore metaDataStore) { + AggregationDataStore.AggregationDataStoreBuilder aggregationDataStoreBuilder = AggregationDataStore.builder(); - return new MultiplexManager(jpaStore, metaDataStore, aggregationDataStore); + if (validator != null) { + aggregationDataStoreBuilder.dynamicCompiledClasses(metaDataStore.getDynamicTypes()); + } + return aggregationDataStoreBuilder + .queryEngine(new SQLQueryEngine(metaDataStore, + (name) -> connectionDetailsMap.getOrDefault(name, defaultConnectionDetails), + new HashSet<>(Arrays.asList(new AggregateBeforeJoinOptimizer(metaDataStore))), + new DefaultQueryPlanMerger(metaDataStore), + new DefaultQueryValidator(metaDataStore.getMetadataDictionary()))) + .queryLogger(new Slf4jQueryLogger()); } @Override diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/NoCacheAggregationDataStoreTestHarness.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/NoCacheAggregationDataStoreTestHarness.java new file mode 100644 index 0000000000..811209e1f8 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/NoCacheAggregationDataStoreTestHarness.java @@ -0,0 +1,49 @@ +/* + * Copyright 2019, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.datastores.aggregation.framework; + +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.datastores.aggregation.AggregationDataStore; +import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; +import com.yahoo.elide.datastores.multiplex.MultiplexManager; +import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; + +import java.util.Collections; +import java.util.Map; + +import javax.persistence.EntityManagerFactory; +import javax.sql.DataSource; + +public class NoCacheAggregationDataStoreTestHarness extends AggregationDataStoreTestHarness { + + public NoCacheAggregationDataStoreTestHarness(EntityManagerFactory entityManagerFactory, ConnectionDetails defaultConnectionDetails, + Map connectionDetailsMap, DynamicConfigValidator validator) { + super(entityManagerFactory, defaultConnectionDetails, connectionDetailsMap, validator); + } + + public NoCacheAggregationDataStoreTestHarness(EntityManagerFactory entityManagerFactory, DataSource defaultDataSource) { + this(entityManagerFactory, new ConnectionDetails(defaultDataSource, SQLDialectFactory.getDefaultDialect())); + } + + public NoCacheAggregationDataStoreTestHarness(EntityManagerFactory entityManagerFactory, + ConnectionDetails defaultConnectionDetails) { + super(entityManagerFactory, defaultConnectionDetails, Collections.emptyMap(), null); + } + + @Override + public DataStore getDataStore() { + + MetaDataStore metaDataStore = createMetaDataStore(); + + AggregationDataStore.AggregationDataStoreBuilder aggregationDataStoreBuilder = createAggregationDataStoreBuilder(metaDataStore); + + DataStore jpaStore = createJPADataStore(); + + return new MultiplexManager(jpaStore, metaDataStore, aggregationDataStoreBuilder.build()); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/RedisAggregationDataStoreTestHarness.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/RedisAggregationDataStoreTestHarness.java new file mode 100644 index 0000000000..48fb07257c --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/RedisAggregationDataStoreTestHarness.java @@ -0,0 +1,57 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.datastores.aggregation.framework; + +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.datastores.aggregation.AggregationDataStore; +import com.yahoo.elide.datastores.aggregation.cache.RedisCache; +import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; +import com.yahoo.elide.datastores.multiplex.MultiplexManager; +import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; + +import redis.clients.jedis.JedisPooled; + +import java.util.Collections; +import java.util.Map; +import javax.persistence.EntityManagerFactory; +import javax.sql.DataSource; + +public class RedisAggregationDataStoreTestHarness extends AggregationDataStoreTestHarness { + private static final String HOST = "localhost"; + private static final int PORT = 6379; + private static final int EXPIRATION_MINUTES = 2; + + public RedisAggregationDataStoreTestHarness(EntityManagerFactory entityManagerFactory, ConnectionDetails defaultConnectionDetails, + Map connectionDetailsMap, DynamicConfigValidator validator) { + super(entityManagerFactory, defaultConnectionDetails, connectionDetailsMap, validator); + } + + public RedisAggregationDataStoreTestHarness(EntityManagerFactory entityManagerFactory, DataSource defaultDataSource) { + super(entityManagerFactory, new ConnectionDetails(defaultDataSource, SQLDialectFactory.getDefaultDialect())); + } + + public RedisAggregationDataStoreTestHarness(EntityManagerFactory entityManagerFactory, + ConnectionDetails defaultConnectionDetails) { + super(entityManagerFactory, defaultConnectionDetails, Collections.emptyMap(), null); + } + + @Override + public DataStore getDataStore() { + JedisPooled jedisPool = new JedisPooled(HOST, PORT); + RedisCache cache = new RedisCache(jedisPool, EXPIRATION_MINUTES); + + MetaDataStore metaDataStore = createMetaDataStore(); + + AggregationDataStore.AggregationDataStoreBuilder aggregationDataStoreBuilder = createAggregationDataStoreBuilder(metaDataStore) + .cache(cache); + + DataStore jpaStore = createJPADataStore(); + + return new MultiplexManager(jpaStore, metaDataStore, aggregationDataStoreBuilder.build()); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/SQLUnitTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/SQLUnitTest.java index c0edfc48fe..ac46713375 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/SQLUnitTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/framework/SQLUnitTest.java @@ -33,6 +33,7 @@ import com.yahoo.elide.datastores.aggregation.QueryEngine; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; import com.yahoo.elide.datastores.aggregation.query.DimensionProjection; import com.yahoo.elide.datastores.aggregation.query.ImmutablePagination; import com.yahoo.elide.datastores.aggregation.query.MetricProjection; @@ -79,6 +80,7 @@ import java.util.Properties; import java.util.Set; import java.util.TreeMap; +import java.util.function.Function; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -469,7 +471,9 @@ public static void init(SQLDialect sqlDialect, Set optimizers, MetaDa Properties properties = new Properties(); properties.put("driverClassName", "org.h2.Driver"); - String jdbcUrl = "jdbc:h2:mem:db1;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE" + getCompatabilityMode(sqlDialect.getDialectType()); + String jdbcUrl = "jdbc:h2:mem:db1;DB_CLOSE_DELAY=-1" + + ";NON_KEYWORDS=VALUE,USER" + + ";DATABASE_TO_UPPER=FALSE" + getCompatabilityMode(sqlDialect.getDialectType()); properties.put("jdbcUrl", jdbcUrl); HikariConfig config = new HikariConfig(properties); DataSource dataSource = new HikariDataSource(config); @@ -494,7 +498,7 @@ public static void init(SQLDialect sqlDialect, Set optimizers, MetaDa dictionary.bindEntity(CountryViewNested.class); dictionary.bindEntity(Continent.class); dictionary.bindEntity(GameRevenue.class); - filterParser = new RSQLFilterDialect(dictionary); + filterParser = RSQLFilterDialect.builder().dictionary(dictionary).build(); //Manually register the serdes because we are not running a complete Elide service. CoerceUtil.register(Day.class, new Day.DaySerde()); @@ -514,8 +518,13 @@ public static void init(SQLDialect sqlDialect, Set optimizers, MetaDa connectionDetailsMap.put("mycon", new ConnectionDetails(dataSource, sqlDialect)); connectionDetailsMap.put("SalesDBConnection", new ConnectionDetails(DUMMY_DATASOURCE, sqlDialect)); - engine = new SQLQueryEngine(metaDataStore, new ConnectionDetails(dataSource, sqlDialect), - connectionDetailsMap, optimizers, new DefaultQueryValidator(metaDataStore.getMetadataDictionary())); + + Function connectionLookup = (name) -> + connectionDetailsMap.getOrDefault(name, new ConnectionDetails(dataSource, sqlDialect)); + engine = new SQLQueryEngine(metaDataStore, connectionLookup, + optimizers, + new DefaultQueryPlanMerger(metaDataStore), + new DefaultQueryValidator(metaDataStore.getMetadataDictionary())); playerStatsTable = (SQLTable) metaDataStore.getTable("playerStats", NO_VERSION); videoGameTable = (SQLTable) metaDataStore.getTable("videoGame", NO_VERSION); playerStatsViewTable = (SQLTable) metaDataStore.getTable("playerStatsView", NO_VERSION); @@ -633,11 +642,11 @@ protected String getExpectedNestedMetricWithSortingQuery(boolean useAliasForOrde + "FROM (SELECT MAX(`example_PlayerStats`.`highScore`) AS `highScore`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` GROUP BY " + "`example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` GROUP BY " + "`example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate` "; @@ -660,11 +669,11 @@ protected String getExpectedNestedMetricWithHavingQuery() { + "FROM (SELECT MAX(`example_PlayerStats`.`highScore`) AS `highScore`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` GROUP BY " + "`example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` GROUP BY " + "`example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate` " @@ -679,13 +688,13 @@ protected String getExpectedNestedMetricWithWhereQuery() { + "FROM (SELECT MAX(`example_PlayerStats`.`highScore`) AS `highScore`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` " + "LEFT OUTER JOIN `countries` AS `example_PlayerStats_country_XXX` ON `example_PlayerStats`.`country_id` = `example_PlayerStats_country_XXX`.`id` " + "WHERE `example_PlayerStats_country_XXX`.`iso_code` IN (:XXX) " + "GROUP BY `example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` GROUP BY " + "`example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate`\n"; @@ -702,11 +711,11 @@ protected String getExpectedNestedMetricQuery() { + "MIN(`example_PlayerStats`.`lowScore`) AS `INNER_AGG_XXX`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` GROUP BY " + "`example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` GROUP BY " + "`example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate`\n"; @@ -729,7 +738,7 @@ protected String getExpectedNestedMetricWithAliasesSQL(boolean useAliasForOrderB + " `example_PlayerStats`.`overallRating` AS `overallRating_207658499`,\n" + " CASE WHEN `example_PlayerStats`.`overallRating` = 'Good' THEN 1 ELSE 2 END AS `playerLevel_96024484`,\n" + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_55557339`,\n" - + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate_155839778` \n" + + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate_155839778` \n" + " FROM \n" + " `playerStats` AS `example_PlayerStats` \n" + " LEFT OUTER JOIN \n" @@ -748,7 +757,7 @@ protected String getExpectedNestedMetricWithAliasesSQL(boolean useAliasForOrderB + " `example_PlayerStats`.`overallRating`, \n" + " CASE WHEN `example_PlayerStats`.`overallRating` = 'Good' THEN 1 ELSE 2 END, \n" + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), \n" - + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') \n" + + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') \n" + " ) AS `example_PlayerStats_XXX` \n" + "GROUP BY \n" + " `example_PlayerStats_XXX`.`overallRating_207658499`, \n" @@ -773,13 +782,13 @@ protected String getExpectedWhereWithArgumentsSQL() { String expectedSQL = "SELECT \n" + " MAX(`example_PlayerStats`.`highScore`) AS `highScore`,\n" - + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` \n" + + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` \n" + "FROM \n" + " `playerStats` AS `example_PlayerStats` \n" + "WHERE \n" + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') IS NOT NULL \n" + "GROUP BY \n" - + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') "; + + " PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') "; return formatInSingleLine(expectedSQL); } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/AggregationDataStoreIntegrationTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/AggregationDataStoreIntegrationTest.java index e43cf10dee..c17c9ad5ca 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/AggregationDataStoreIntegrationTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/AggregationDataStoreIntegrationTest.java @@ -5,37 +5,18 @@ */ package com.yahoo.elide.datastores.aggregation.integration; -import static com.yahoo.elide.test.graphql.GraphQLDSL.argument; -import static com.yahoo.elide.test.graphql.GraphQLDSL.arguments; -import static com.yahoo.elide.test.graphql.GraphQLDSL.document; -import static com.yahoo.elide.test.graphql.GraphQLDSL.field; -import static com.yahoo.elide.test.graphql.GraphQLDSL.mutation; -import static com.yahoo.elide.test.graphql.GraphQLDSL.selection; -import static com.yahoo.elide.test.graphql.GraphQLDSL.selections; -import static io.restassured.RestAssured.given; -import static io.restassured.RestAssured.when; -import static org.hamcrest.Matchers.allOf; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.hasEntry; -import static org.hamcrest.Matchers.hasItems; -import static org.hamcrest.Matchers.hasSize; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.when; + import com.yahoo.elide.Elide; import com.yahoo.elide.ElideSettingsBuilder; import com.yahoo.elide.core.audit.TestAuditLogger; -import com.yahoo.elide.core.datastore.test.DataStoreTestHarness; import com.yahoo.elide.core.dictionary.EntityDictionary; -import com.yahoo.elide.core.exceptions.HttpStatus; import com.yahoo.elide.core.security.checks.Check; import com.yahoo.elide.core.security.checks.prefab.Role; import com.yahoo.elide.core.utils.DefaultClassScanner; import com.yahoo.elide.datastores.aggregation.AggregationDataStore; -import com.yahoo.elide.datastores.aggregation.checks.OperatorCheck; -import com.yahoo.elide.datastores.aggregation.checks.VideoGameFilterCheck; -import com.yahoo.elide.datastores.aggregation.framework.AggregationDataStoreTestHarness; import com.yahoo.elide.datastores.aggregation.framework.SQLUnitTest; -import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; import com.yahoo.elide.datastores.aggregation.queryengines.sql.DataSourceConfiguration; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialect; @@ -45,26 +26,19 @@ import com.yahoo.elide.modelconfig.DBPasswordExtractor; import com.yahoo.elide.modelconfig.model.DBConfig; import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; + import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; -import example.PlayerStats; import example.TestCheckMappings; import org.glassfish.jersey.internal.inject.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; import java.io.File; import java.io.IOException; import java.io.UnsupportedEncodingException; -import java.util.Arrays; import java.util.Base64; import java.util.Calendar; import java.util.HashMap; @@ -75,21 +49,18 @@ import javax.persistence.Persistence; import javax.sql.DataSource; import javax.ws.rs.container.ContainerRequestFilter; -import javax.ws.rs.core.MediaType; import javax.ws.rs.core.SecurityContext; /** * Integration tests for {@link AggregationDataStore}. */ -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -public class AggregationDataStoreIntegrationTest extends GraphQLIntegrationTest { - - @Mock private static SecurityContext securityContextMock; +public abstract class AggregationDataStoreIntegrationTest extends GraphQLIntegrationTest { + @Mock protected static SecurityContext securityContextMock; public static DynamicConfigValidator VALIDATOR; + public static HikariConfig config = new HikariConfig(File.separator + "jpah2db.properties"); + static { VALIDATOR = new DynamicConfigValidator(DefaultClassScanner.getInstance(), "src/test/resources/configs"); @@ -100,7 +71,7 @@ public class AggregationDataStoreIntegrationTest extends GraphQLIntegrationTest } } - private static final class SecurityHjsonIntegrationTestResourceConfig extends ResourceConfig { + protected static final class SecurityHjsonIntegrationTestResourceConfig extends ResourceConfig { @Inject public SecurityHjsonIntegrationTestResourceConfig() { @@ -108,8 +79,6 @@ public SecurityHjsonIntegrationTestResourceConfig() { @Override protected void configure() { Map> map = new HashMap<>(TestCheckMappings.MAPPINGS); - map.put(OperatorCheck.OPERTOR_CHECK, OperatorCheck.class); - map.put(VideoGameFilterCheck.NAME_FILTER, VideoGameFilterCheck.class); EntityDictionary dictionary = EntityDictionary.builder().checks(map).build(); VALIDATOR.getElideSecurityConfig().getRoles().forEach(role -> @@ -121,6 +90,8 @@ protected void configure() { .withAuditLogger(new TestAuditLogger()) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", Calendar.getInstance().getTimeZone()) .build()); + + elide.doScans(); bind(elide).to(Elide.class).named("elide"); } }); @@ -132,33 +103,21 @@ public AggregationDataStoreIntegrationTest() { super(SecurityHjsonIntegrationTestResourceConfig.class, JsonApiEndpoint.class.getPackage().getName()); } - @BeforeAll - public void beforeAll() { - SQLUnitTest.init(); - } - - @BeforeEach - public void setUp() { - reset(securityContextMock); - when(securityContextMock.isUserInRole("admin.user")).thenReturn(true); - when(securityContextMock.isUserInRole("operator")).thenReturn(true); - when(securityContextMock.isUserInRole("guest user")).thenReturn(true); - } - - @Override - protected DataStoreTestHarness createHarness() { - - HikariConfig config = new HikariConfig(File.separator + "jpah2db.properties"); + protected ConnectionDetails createDefaultConnectionDetails() { DataSource defaultDataSource = new HikariDataSource(config); SQLDialect defaultDialect = SQLDialectFactory.getDefaultDialect(); - ConnectionDetails defaultConnectionDetails = new ConnectionDetails(defaultDataSource, defaultDialect); + return new ConnectionDetails(defaultDataSource, defaultDialect); + } - Properties prop = new Properties(); + protected EntityManagerFactory createEntityManagerFactory() { + Properties prop = new Properties(); prop.put("javax.persistence.jdbc.driver", config.getDriverClassName()); prop.put("javax.persistence.jdbc.url", config.getJdbcUrl()); - EntityManagerFactory emf = Persistence.createEntityManagerFactory("aggregationStore", prop); + return Persistence.createEntityManagerFactory("aggregationStore", prop); + } - Map connectionDetailsMap = new HashMap<>(); + protected Map createConnectionDetailsMap(ConnectionDetails defaultConnectionDetails) { + Map connectionDetailsMap = new HashMap<>(); // Add an entry for "mycon" connection which is not from hjson connectionDetailsMap.put("mycon", defaultConnectionDetails); @@ -169,7 +128,7 @@ protected DataStoreTestHarness createHarness() { SQLDialectFactory.getDialect(dbConfig.getDialect()))) ); - return new AggregationDataStoreTestHarness(emf, defaultConnectionDetails, connectionDetailsMap, VALIDATOR); + return connectionDetailsMap; } static DataSource getDataSource(DBConfig dbConfig, DBPasswordExtractor dbPasswordExtractor) { @@ -192,1655 +151,16 @@ public String getDBPassword(DBConfig config) { }; } - @Test - public void testGraphQLSchema() throws IOException { - String graphQLRequest = "{" - + "__type(name: \"PlayerStatsWithViewEdge\") {" - + " name " - + " fields {" - + " name " - + " type {" - + " name" - + " fields {" - + " name " - + " type {" - + " name " - + " fields {" - + " name" - + " }" - + " }" - + " }" - + " }" - + " }" - + "}" - + "}"; - - String expected = loadGraphQLResponse("testGraphQLSchema.json"); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void testGraphQLMetdata() throws Exception { - String graphQLRequest = document( - selection( - field( - "table", - arguments( - argument("ids", Arrays.asList("playerStatsView")) - ), - selections( - field("name"), - field("arguments", - selections( - field("name"), - field("type"), - field("defaultValue") - ) - - ) - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "table", - selections( - field("name", "playerStatsView"), - field("arguments", - selections( - field("name", "rating"), - field("type", "TEXT"), - field("defaultValue", "") - ), - selections( - field("name", "minScore"), - field("type", "INTEGER"), - field("defaultValue", "0") - ) - ) - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void basicAggregationTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("sort", "\"highScore\"") - ), - selections( - field("highScore"), - field("overallRating"), - field("countryIsoCode"), - field("playerRank") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("highScore", 1000), - field("overallRating", "Good"), - field("countryIsoCode", "HKG"), - field("playerRank", 3) - ), - selections( - field("highScore", 1234), - field("overallRating", "Good"), - field("countryIsoCode", "USA"), - field("playerRank", 1) - ), - selections( - field("highScore", 2412), - field("overallRating", "Great"), - field("countryIsoCode", "USA"), - field("playerRank", 2) - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void metricFormulaTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "videoGame", - arguments( - argument("sort", "\"timeSpentPerSession\"") - ), - selections( - field("timeSpent"), - field("sessions"), - field("timeSpentPerSession"), - field("playerName") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "videoGame", - selections( - field("timeSpent", 720), - field("sessions", 60), - field("timeSpentPerSession", 12.0), - field("playerName", "Jon Doe") - ), - selections( - field("timeSpent", 350), - field("sessions", 25), - field("timeSpentPerSession", 14.0), - field("playerName", "Jane Doe") - ), - selections( - field("timeSpent", 300), - field("sessions", 10), - field("timeSpentPerSession", 30.0), - field("playerName", "Han") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - - //When admin = false - - when(securityContextMock.isUserInRole("admin.user")).thenReturn(false); - - expected = document( - selections( - field( - "videoGame", - selections( - field("timeSpent", 720), - field("sessions", 60), - field("timeSpentPerSession", 12.0), - field("playerName", "Jon Doe") - ), - selections( - field("timeSpent", 350), - field("sessions", 25), - field("timeSpentPerSession", 14.0), - field("playerName", "Jane Doe") - ) - ) - ) - ).toResponse(); - runQueryWithExpectedResult(graphQLRequest, expected); - } - - /** - * Test sql expression in where, sorting, group by and projection. - * @throws Exception exception - */ - @Test - public void dimensionFormulaTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("sort", "\"playerLevel\""), - argument("filter", "\"playerLevel>\\\"0\\\"\"") - ), - selections( - field("highScore"), - field("playerLevel") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("highScore", 1234), - field("playerLevel", 1) - ), - selections( - field("highScore", 2412), - field("playerLevel", 2) - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void noMetricQueryTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStatsWithView", - arguments( - argument("sort", "\"countryViewViewIsoCode\"") - ), - selections( - field("countryViewViewIsoCode") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStatsWithView", - selections( - field("countryViewViewIsoCode", "HKG") - ), - selections( - field("countryViewViewIsoCode", "USA") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void whereFilterTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("filter", "\"overallRating==\\\"Good\\\"\"") - ), - selections( - field("highScore"), - field("overallRating") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("highScore", 1234), - field("overallRating", "Good") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void havingFilterTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("filter", "\"lowScore<\\\"45\\\"\"") - ), - selections( - field("lowScore"), - field("overallRating"), - field("playerName") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("lowScore", 35), - field("overallRating", "Good"), - field("playerName", "Jon Doe") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - /** - * Test the case that a where clause is promoted into having clause. - * @throws Exception exception - */ - @Test - public void wherePromotionTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("filter", "\"overallRating==\\\"Good\\\",lowScore<\\\"45\\\"\""), - argument("sort", "\"lowScore\"") - ), - selections( - field("lowScore"), - field("overallRating"), - field("playerName") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("lowScore", 35), - field("overallRating", "Good"), - field("playerName", "Jon Doe") - ), - selections( - field("lowScore", 72), - field("overallRating", "Good"), - field("playerName", "Han") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - /** - * Test the case that a where clause, which requires dimension join, is promoted into having clause. - * @throws Exception exception - */ - @Test - public void havingClauseJoinTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("filter", "\"countryIsoCode==\\\"USA\\\",lowScore<\\\"45\\\"\""), - argument("sort", "\"lowScore\"") - ), - selections( - field("lowScore"), - field("countryIsoCode"), - field("playerName") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("lowScore", 35), - field("countryIsoCode", "USA"), - field("playerName", "Jon Doe") - ), - selections( - field("lowScore", 241), - field("countryIsoCode", "USA"), - field("playerName", "Jane Doe") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - /** - * Test invalid where promotion on a dimension field that is not grouped. - * @throws Exception exception - */ - @Test - public void ungroupedHavingDimensionTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("filter", "\"countryIsoCode==\\\"USA\\\",lowScore<\\\"45\\\"\"") - ), - selections( - field("lowScore") - ) - ) - ) - ).toQuery(); - - String errorMessage = "Exception while fetching data (/playerStats) : Invalid operation: " - + "Post aggregation filtering on 'countryIsoCode' requires the field to be projected in the response"; - - runQueryWithExpectedError(graphQLRequest, errorMessage); - } - - /** - * Test invalid having clause on a metric field that is not aggregated. - * @throws Exception exception - */ - @Test - public void nonAggregatedHavingMetricTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("filter", "\"highScore>\\\"0\\\"\"") - ), - selections( - field("lowScore") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("lowScore", 35) - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - /** - * Test invalid where promotion on a different class than the queried class. - * @throws Exception exception - */ - @Test - public void invalidHavingClauseClassTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("filter", "\"country.isoCode==\\\"USA\\\",lowScore<\\\"45\\\"\"") - ), - selections( - field("lowScore") - ) - ) - ) - ).toQuery(); - - String errorMessage = "Exception while fetching data (/playerStats) : Invalid operation: " - + "Relationship traversal not supported for analytic queries."; - - runQueryWithExpectedError(graphQLRequest, errorMessage); - } - - @Test - public void dimensionSortingTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("sort", "\"overallRating\"") - ), - selections( - field("lowScore"), - field("overallRating") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("lowScore", 35), - field("overallRating", "Good") - ), - selections( - field("lowScore", 241), - field("overallRating", "Great") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void metricSortingTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("sort", "\"-highScore\"") - ), - selections( - field("highScore"), - field("countryIsoCode") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("highScore", 2412), - field("countryIsoCode", "USA") - ), - selections( - field("highScore", 1000), - field("countryIsoCode", "HKG") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void multipleColumnsSortingTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("sort", "\"overallRating,playerName\"") - ), - selections( - field("lowScore"), - field("overallRating"), - field("playerName") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("lowScore", 72), - field("overallRating", "Good"), - field("playerName", "Han") - ), - selections( - field("lowScore", 35), - field("overallRating", "Good"), - field("playerName", "Jon Doe") - ), - selections( - field("lowScore", 241), - field("overallRating", "Great"), - field("playerName", "Jane Doe") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void idSortingTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("sort", "\"id\"") - ), - selections( - field("lowScore"), - field("id") - ) - ) - ) - ).toQuery(); - - String expected = "Exception while fetching data (/playerStats) : Invalid operation: Sorting on id field is not permitted"; - - runQueryWithExpectedError(graphQLRequest, expected); - } - - @Test - public void nestedDimensionNotInQuerySortingTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("sort", "\"-countryIsoCode,lowScore\"") - ), - selections( - field("lowScore") - ) - ) - ) - ).toQuery(); - - String expected = "Exception while fetching data (/playerStats) : Invalid operation: Can not sort on countryIsoCode as it is not present in query"; - - runQueryWithExpectedError(graphQLRequest, expected); - } - - @Test - public void sortingOnMetricNotInQueryTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("sort", "\"highScore\"") - ), - selections( - field("lowScore"), - field("countryIsoCode") - ) - ) - ) - ).toQuery(); - - String expected = "Exception while fetching data (/playerStats) : Invalid operation: Can not sort on highScore as it is not present in query"; - - runQueryWithExpectedError(graphQLRequest, expected); - } - - @Test - public void basicViewAggregationTest() throws Exception { - String graphQLRequest = document( - selection( - field( - "playerStatsWithView", - arguments( - argument("sort", "\"highScore\"") - ), - selections( - field("highScore"), - field("countryViewIsoCode") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStatsWithView", - selections( - field("highScore", 1000), - field("countryViewIsoCode", "HKG") - ), - selections( - field("highScore", 2412), - field("countryViewIsoCode", "USA") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void multiTimeDimensionTest() throws IOException { - String graphQLRequest = document( - selection( - field( - "playerStats", - selections( - field("recordedDate"), - field("updatedDate") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("recordedDate", "2019-07-13"), - field("updatedDate", "2020-01-12") - ), - selections( - field("recordedDate", "2019-07-12"), - field("updatedDate", "2019-10-12") - ), - selections( - field("recordedDate", "2019-07-11"), - field("updatedDate", "2020-07-12") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void testGraphqlQueryDynamicModelById() throws IOException { - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") - ), - selections( - field("id"), - field("orderTotal") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "SalesNamespace_orderDetails", - selections( - field("id", "0"), - field("orderTotal", 434.84) - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void jsonApiAggregationTest() { - given() - .accept("application/vnd.api+json") - .get("/playerStats") - .then() - .statusCode(HttpStatus.SC_OK) - .body("data.id", hasItems("0", "1", "2")) - .body("data.attributes.highScore", hasItems(1000, 1234, 2412)) - .body("data.attributes.countryIsoCode", hasItems("USA", "HKG")); - } - - /** - * Below tests demonstrate using the aggregation store from dynamic configuration. - */ - @Test - public void testDynamicAggregationModel() { - String getPath = "/SalesNamespace_orderDetails?sort=customerRegion,orderTime&page[totals]&" - + "fields[SalesNamespace_orderDetails]=orderTotal,customerRegion,orderTime&filter=deliveryTime>=2020-01-01;deliveryTime<2020-12-31;orderTime>=2020-08"; - given() - .when() - .get(getPath) - .then() - .statusCode(HttpStatus.SC_OK) - .body("data", hasSize(3)) - .body("data.id", hasItems("0", "1", "2")) - .body("data.attributes", hasItems( - allOf(hasEntry("customerRegion", "NewYork"), hasEntry("orderTime", "2020-08")), - allOf(hasEntry("customerRegion", "Virginia"), hasEntry("orderTime", "2020-08")), - allOf(hasEntry("customerRegion", "Virginia"), hasEntry("orderTime", "2020-09")))) - .body("data.attributes.orderTotal", hasItems(61.43F, 113.07F, 260.34F)) - .body("meta.page.number", equalTo(1)) - .body("meta.page.totalRecords", equalTo(3)) - .body("meta.page.totalPages", equalTo(1)) - .body("meta.page.limit", equalTo(500)); - } - - @Test - public void testInvalidSparseFields() { - String expectedError = "Invalid value: SalesNamespace_orderDetails does not contain the fields: [orderValue, customerState]"; - String getPath = "/SalesNamespace_orderDetails?fields[SalesNamespace_orderDetails]=orderValue,customerState,orderTime"; - given() - .when() - .get(getPath) - .then() - .statusCode(HttpStatus.SC_BAD_REQUEST) - .body("errors.detail", hasItems(expectedError)); - } - - @Test - public void missingClientFilterTest() { - String expectedError = "Querying SalesNamespace_orderDetails requires a mandatory filter:" - + " deliveryTime>={{start}};deliveryTime<{{end}}"; - when() - .get("/SalesNamespace_orderDetails/") - .then() - .body("errors.detail", hasItems(expectedError)) - .statusCode(HttpStatus.SC_BAD_REQUEST); - } - - @Test - public void incompleteClientFilterTest() { - String expectedError = "Querying SalesNamespace_orderDetails requires a mandatory filter:" - + " deliveryTime>={{start}};deliveryTime<{{end}}"; - when() - .get("/SalesNamespace_orderDetails?filter=deliveryTime>=2020-08") - .then() - .body("errors.detail", hasItems(expectedError)) - .statusCode(HttpStatus.SC_BAD_REQUEST); - } - - @Test - public void completeClientFilterTest() { - when() - .get("/SalesNamespace_deliveryDetails?filter=month>=2020-08;month<2020-09") - .then() - .statusCode(HttpStatus.SC_OK); - } - - @Test - public void testGraphQLDynamicAggregationModel() throws Exception { - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"customerRegion\""), - argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime=='2020-08'\"") - ), - selections( - field("orderTotal"), - field("customerRegion"), - field("customerRegionRegion"), - field("orderTime", arguments( - argument("grain", TimeGrain.MONTH) - )) - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "SalesNamespace_orderDetails", - selections( - field("orderTotal", 61.43F), - field("customerRegion", "NewYork"), - field("customerRegionRegion", "NewYork"), - field("orderTime", "2020-08") - ), - selections( - field("orderTotal", 113.07F), - field("customerRegion", "Virginia"), - field("customerRegionRegion", "Virginia"), - field("orderTime", "2020-08") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - /** - * Tests for below type of column references. - * - * a) Physical Column Reference in same table. - * b) Logical Column Reference in same table, which references Physical column in same table. - * c) Logical Column Reference in same table, which references another Logical column in same table, which - * references Physical column in same table. - * d) Physical Column Reference in referred table. - * e) Logical Column Reference in referred table, which references Physical column in referred table. - * f) Logical Column Reference in referred table, which references another Logical column in referred table, which - * references another Logical column in referred table, which references Physical column in referred table. - * g) Logical Column Reference in same table, which references Physical column in referred table. - * h) Logical Column Reference in same table, which references another Logical Column in referred table, which - * references another Logical column in referred table, which references another Logical column in referred table, - * which references Physical column in referred table - * - * @throws Exception - */ - @Test - public void testGraphQLDynamicAggregationModelAllFields() throws Exception { - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"courierName,deliveryDate,orderTotal\""), - argument("filter", "\"deliveryYear=='2020';(deliveryTime>='2020-08-01';deliveryTime<'2020-12-31');(deliveryDate>='2020-09-01',orderTotal>50)\"") - ), - selections( - field("courierName"), - field("deliveryTime"), - field("deliveryHour"), - field("deliveryDate"), - field("deliveryMonth"), - field("deliveryYear"), - field("deliveryDefault"), - field("orderTime", "bySecond", arguments( - argument("grain", TimeGrain.SECOND) - )), - field("orderTime", "byDay", arguments( - argument("grain", TimeGrain.DAY) - )), - field("orderTime", "byMonth", arguments( - argument("grain", TimeGrain.MONTH) - )), - field("customerRegion"), - field("customerRegionRegion"), - field("orderTotal"), - field("zipCode"), - field("orderId") - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "SalesNamespace_orderDetails", - selections( - field("courierName", "FEDEX"), - field("deliveryTime", "2020-09-11T16:30:11"), - field("deliveryHour", "2020-09-11T16"), - field("deliveryDate", "2020-09-11"), - field("deliveryMonth", "2020-09"), - field("deliveryYear", "2020"), - field("bySecond", "2020-09-08T16:30:11"), - field("deliveryDefault", "2020-09-11"), - field("byDay", "2020-09-08"), - field("byMonth", "2020-09"), - field("customerRegion", "Virginia"), - field("customerRegionRegion", "Virginia"), - field("orderTotal", 84.11F), - field("zipCode", 20166), - field("orderId", "order-1b") - ), - selections( - field("courierName", "FEDEX"), - field("deliveryTime", "2020-09-11T16:30:11"), - field("deliveryHour", "2020-09-11T16"), - field("deliveryDate", "2020-09-11"), - field("deliveryMonth", "2020-09"), - field("deliveryYear", "2020"), - field("bySecond", "2020-09-08T16:30:11"), - field("deliveryDefault", "2020-09-11"), - field("byDay", "2020-09-08"), - field("byMonth", "2020-09"), - field("customerRegion", "Virginia"), - field("customerRegionRegion", "Virginia"), - field("orderTotal", 97.36F), - field("zipCode", 20166), - field("orderId", "order-1c") - ), - selections( - field("courierName", "UPS"), - field("deliveryTime", "2020-09-05T16:30:11"), - field("deliveryHour", "2020-09-05T16"), - field("deliveryDate", "2020-09-05"), - field("deliveryMonth", "2020-09"), - field("deliveryYear", "2020"), - field("bySecond", "2020-08-30T16:30:11"), - field("deliveryDefault", "2020-09-05"), - field("byDay", "2020-08-30"), - field("byMonth", "2020-08"), - field("customerRegion", "Virginia"), - field("customerRegionRegion", "Virginia"), - field("orderTotal", 103.72F), - field("zipCode", 20166), - field("orderId", "order-1a") - ), - selections( - field("courierName", "UPS"), - field("deliveryTime", "2020-09-13T16:30:11"), - field("deliveryHour", "2020-09-13T16"), - field("deliveryDate", "2020-09-13"), - field("deliveryMonth", "2020-09"), - field("deliveryYear", "2020"), - field("bySecond", "2020-09-09T16:30:11"), - field("deliveryDefault", "2020-09-13"), - field("byDay", "2020-09-09"), - field("byMonth", "2020-09"), - field("customerRegion", "Virginia"), - field("customerRegionRegion", "Virginia"), - field("orderTotal", 78.87F), - field("zipCode", 20170), - field("orderId", "order-3b") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void testGraphQLDynamicAggregationModelDateTime() throws Exception { - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"customerRegion\""), - argument("filter", "\"bySecond=='2020-09-08T16:30:11';(deliveryTime>='2020-01-01';deliveryTime<'2020-12-31')\"") - ), - selections( - field("orderTotal"), - field("customerRegion"), - field("orderTime", "byMonth", arguments( - argument("grain", TimeGrain.MONTH) - )), - field("orderTime", "bySecond", arguments( - argument("grain", TimeGrain.SECOND) - )) - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "SalesNamespace_orderDetails", - selections( - field("orderTotal", 181.47F), - field("customerRegion", "Virginia"), - field("byMonth", "2020-09"), - field("bySecond", "2020-09-08T16:30:11") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void testTimeDimMismatchArgs() throws Exception { - - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"customerRegion\""), - argument("filter", "\"orderTime[grain:DAY]=='2020-08',orderTotal>50\"") - ), - selections( - field("orderTotal"), - field("customerRegion"), - field("orderTime", arguments( - argument("grain", TimeGrain.MONTH) // Does not match grain argument in filter - )) - ) - ) - ) - ).toQuery(); - - String expected = "Exception while fetching data (/SalesNamespace_orderDetails) : Invalid operation: Post aggregation filtering on 'orderTime' requires the field to be projected in the response with matching arguments"; - - runQueryWithExpectedError(graphQLRequest, expected); - } - - @Test - public void testTimeDimMismatchArgsWithDefaultSelect() throws Exception { - - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"customerRegion\""), - argument("filter", "\"orderTime[grain:DAY]=='2020-08',orderTotal>50\"") - ), - selections( - field("orderTotal"), - field("customerRegion"), - field("orderTime") //Default Grain for OrderTime is Month. - ) - ) - ) - ).toQuery(); - - String expected = "Exception while fetching data (/SalesNamespace_orderDetails) : Invalid operation: Post aggregation filtering on 'orderTime' requires the field to be projected in the response with matching arguments"; - - - runQueryWithExpectedError(graphQLRequest, expected); - } - - @Test - public void testTimeDimMismatchArgsWithDefaultFilter() throws Exception { - - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"orderTime\""), - argument("filter", "\"(orderTime=='2020-08-01',orderTotal>50);(deliveryTime>='2020-01-01';deliveryTime<'2020-12-31')\"") //No Grain Arg passed, so works based on Alias's argument in Selection. - ), - selections( - field("orderTotal"), - field("customerRegion"), - field("orderTime", arguments( - argument("grain", TimeGrain.DAY) - )) - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "SalesNamespace_orderDetails", - selections( - field("orderTotal", 103.72F), - field("customerRegion", "Virginia"), - field("orderTime", "2020-08-30") - ), - selections( - field("orderTotal", 181.47F), - field("customerRegion", "Virginia"), - field("orderTime", "2020-09-08") - ), - selections( - field("orderTotal", 78.87F), - field("customerRegion", "Virginia"), - field("orderTime", "2020-09-09") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void testAdminRole() throws Exception { - - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"customerRegion\""), - argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime=='2020-08'\"") - ), - selections( - field("orderTotal"), - field("customerRegion"), - field("orderTime", arguments( - argument("grain", TimeGrain.MONTH) - )) - ) - ) - ) - ).toQuery(); - - String expected = document( - selection( - field( - "SalesNamespace_orderDetails", - selections( - field("orderTotal", 61.43F), - field("customerRegion", "NewYork"), - field("orderTime", "2020-08") - ), - selections( - field("orderTotal", 113.07F), - field("customerRegion", "Virginia"), - field("orderTime", "2020-08") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void testOperatorRole() throws Exception { - - when(securityContextMock.isUserInRole("admin")).thenReturn(false); - - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"customerRegion\""), - argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime=='2020-08'\"") - ), - selections( - field("customerRegion"), - field("orderTime", arguments( - argument("grain", TimeGrain.MONTH) - )) - ) - ) - ) - ).toQuery(); - - String expected = document( - selection( - field( - "SalesNamespace_orderDetails", - selections( - field("customerRegion", "NewYork"), - field("orderTime", "2020-08") - ), - selections( - field("customerRegion", "Virginia"), - field("orderTime", "2020-08") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void testGuestUserRole() throws Exception { - - when(securityContextMock.isUserInRole("admin")).thenReturn(false); - when(securityContextMock.isUserInRole("operator")).thenReturn(false); - - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"customerRegion\""), - argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime=='2020-08'\"") - ), - selections( - field("customerRegion"), - field("orderTime", arguments( - argument("grain", TimeGrain.MONTH) - )) - ) - ) - ) - ).toQuery(); - - String expected = "Exception while fetching data (/SalesNamespace_orderDetails/edges[0]/node/customerRegion) : ReadPermission Denied"; - - runQueryWithExpectedError(graphQLRequest, expected); - } - - @Test - public void testTimeDimensionAliases() throws Exception { - - String graphQLRequest = document( - selection( - field( - "playerStats", - arguments( - argument("filter", "\"byDay>='2019-07-12'\""), - argument("sort", "\"byDay\"") - ), - selections( - field("highScore"), - field("recordedDate", "byDay", arguments( - argument("grain", TimeGrain.DAY) - )), - field("recordedDate", "byMonth", arguments( - argument("grain", TimeGrain.MONTH) - )), - field("recordedDate", "byQuarter", arguments( - argument("grain", TimeGrain.QUARTER) - )) - ) - ) - ) - ).toQuery(); - - String expected = document( - selections( - field( - "playerStats", - selections( - field("highScore", 1234), - field("byDay", "2019-07-12"), - field("byMonth", "2019-07"), - field("byQuarter", "2019-07") - ), - selections( - field("highScore", 1000), - field("byDay", "2019-07-13"), - field("byMonth", "2019-07"), - field("byQuarter", "2019-07") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void testTimeDimensionArgumentsInFilter() throws Exception { - - String graphQLRequest = document( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("sort", "\"customerRegion\""), - argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime[grain:day]=='2020-09-08'\"") - ), - selections( - field("customerRegion"), - field("orderTotal"), - field("orderTime", arguments( - argument("grain", TimeGrain.MONTH) - )) - ) - ) - ) - ).toQuery(); - - String expected = document( - selection( - field( - "SalesNamespace_orderDetails", - selections( - field("customerRegion", "Virginia"), - field("orderTotal", 181.47F), - field("orderTime", "2020-09") - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - } - - @Test - public void testSchemaIntrospection() throws Exception { - String graphQLRequest = "{" - + "__schema {" - + " mutationType {" - + " name " - + " fields {" - + " name " - + " args {" - + " name" - + " defaultValue" - + " }" - + " }" - + " }" - + "}" - + "}"; - - String query = toJsonQuery(graphQLRequest, new HashMap<>()); - - given() - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .body(query) - .post("/graphQL") - .then() - .statusCode(HttpStatus.SC_OK) - // Verify that the SalesNamespace_orderDetails Model has an argument "denominator". - .body("data.__schema.mutationType.fields.find { it.name == 'SalesNamespace_orderDetails' }.args.name[7] ", equalTo("denominator")); - - graphQLRequest = "{" - + "__type(name: \"SalesNamespace_orderDetails\") {" - + " name" - + " fields {" - + " name " - + " args {" - + " name" - + " defaultValue" - + " }" - + " }" - + "}" - + "}"; - - query = toJsonQuery(graphQLRequest, new HashMap<>()); - - given() - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .body(query) - .post("/graphQL") - .then() - .statusCode(HttpStatus.SC_OK) - // Verify that the orderTotal attribute has an argument "precision". - .body("data.__type.fields.find { it.name == 'orderTotal' }.args.name[0]", equalTo("precision")); - } - - @Test - public void testDelete() throws IOException { - String graphQLRequest = mutation( - selection( - field( - "playerStats", - arguments( - argument("op", "DELETE"), - argument("ids", Arrays.asList("0")) - ), - selections( - field("id"), - field("overallRating") - ) - ) - ) - ).toGraphQLSpec(); - - String expected = "Exception while fetching data (/playerStats) : Invalid operation: Filtering by ID is not supported on playerStats"; - - runQueryWithExpectedError(graphQLRequest, expected); - } - - @Test - public void testUpdate() throws IOException { - - PlayerStats playerStats = new PlayerStats(); - playerStats.setId("1"); - playerStats.setHighScore(100); - - String graphQLRequest = mutation( - selection( - field( - "playerStats", - arguments( - argument("op", "UPDATE"), - argument("data", playerStats) - ), - selections( - field("id"), - field("overallRating") - ) - ) - ) - ).toGraphQLSpec(); - - String expected = "Exception while fetching data (/playerStats) : Invalid operation: Filtering by ID is not supported on playerStats"; - - runQueryWithExpectedError(graphQLRequest, expected); - } - - @Test - public void testUpsertWithStaticModel() throws IOException { - - PlayerStats playerStats = new PlayerStats(); - playerStats.setId("1"); - playerStats.setHighScore(100); - - String graphQLRequest = mutation( - selection( - field( - "playerStats", - arguments( - argument("op", "UPSERT"), - argument("data", playerStats) - ), - selections( - field("id"), - field("overallRating") - ) - ) - ) - ).toGraphQLSpec(); - - String expected = "Exception while fetching data (/playerStats) : Invalid operation: Filtering by ID is not supported on playerStats"; - - runQueryWithExpectedError(graphQLRequest, expected); - } - - @Test - public void testUpsertWithDynamicModel() throws IOException { - - Map order = new HashMap<>(); - order.put("orderId", "1"); - order.put("courierName", "foo"); - - String graphQLRequest = mutation( - selection( - field( - "SalesNamespace_orderDetails", - arguments( - argument("op", "UPSERT"), - argument("data", order) - ), - selections( - field("orderId") - ) - ) - ) - ).toGraphQLSpec(); - - String expected = "Invalid operation: SalesNamespace_orderDetails is read only."; - - runQueryWithExpectedError(graphQLRequest, expected); - } - - /** - * Test missing required column filter on deliveryYear. - * @throws Exception exception - */ - @Test - public void missingRequiredColumnFilter() throws Exception { - String graphQLRequest = document( - selection( - - field( - "SalesNamespace_orderDetails", - arguments( - argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") - ), - selections( - field("orderTotal"), - field("deliveryYear") - ) - ) - ) - ).toQuery(); - - String errorMessage = "Exception while fetching data (/SalesNamespace_orderDetails) : " - + "Querying deliveryYear requires a mandatory filter: deliveryYear=={{deliveryYear}}"; - - runQueryWithExpectedError(graphQLRequest, errorMessage); - } - - //Security - @Test - public void testPermissionFilters() throws IOException { - when(securityContextMock.isUserInRole("admin.user")).thenReturn(false); - - String graphQLRequest = document( - selection( - field( - "videoGame", - arguments( - argument("sort", "\"timeSpentPerSession\"") - ), - selections( - field("timeSpent"), - field("sessions"), - field("timeSpentPerSession") - ) - ) - ) - ).toQuery(); - - //Records for Jon Doe and Jane Doe will only be aggregated. - String expected = document( - selections( - field( - "videoGame", - selections( - field("timeSpent", 1070), - field("sessions", 85), - field("timeSpentPerSession", 12.588235) - ) - ) - ) - ).toResponse(); - - runQueryWithExpectedResult(graphQLRequest, expected); - + @BeforeAll + public void beforeAll() { + SQLUnitTest.init(); } - @Test - public void testFieldPermissions() throws IOException { - when(securityContextMock.isUserInRole("operator")).thenReturn(false); - String graphQLRequest = document( - selection( - field( - "videoGame", - selections( - field("timeSpent"), - field("sessions"), - field("timeSpentPerSession"), - field("timeSpentPerGame") - ) - ) - ) - ).toQuery(); - - String expected = "Exception while fetching data (/videoGame/edges[0]/node/timeSpentPerGame) : ReadPermission Denied"; - - runQueryWithExpectedError(graphQLRequest, expected); - + @BeforeEach + public void setUp() { + reset(securityContextMock); + when(securityContextMock.isUserInRole("admin.user")).thenReturn(true); + when(securityContextMock.isUserInRole("operator")).thenReturn(true); + when(securityContextMock.isUserInRole("guest user")).thenReturn(true); } } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/MetaDataStoreIntegrationTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/MetaDataStoreIntegrationTest.java index 289661e37c..7935260514 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/MetaDataStoreIntegrationTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/MetaDataStoreIntegrationTest.java @@ -18,6 +18,7 @@ import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; import com.yahoo.elide.Elide; import com.yahoo.elide.ElideSettingsBuilder; import com.yahoo.elide.core.datastore.test.DataStoreTestHarness; @@ -30,7 +31,7 @@ import com.yahoo.elide.core.security.checks.prefab.Role; import com.yahoo.elide.datastores.aggregation.checks.OperatorCheck; import com.yahoo.elide.datastores.aggregation.checks.VideoGameFilterCheck; -import com.yahoo.elide.datastores.aggregation.framework.AggregationDataStoreTestHarness; +import com.yahoo.elide.datastores.aggregation.framework.NoCacheAggregationDataStoreTestHarness; import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialect; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; @@ -74,7 +75,7 @@ protected void configure() { ); DefaultFilterDialect defaultFilterStrategy = new DefaultFilterDialect(dictionary); - RSQLFilterDialect rsqlFilterStrategy = new RSQLFilterDialect(dictionary); + RSQLFilterDialect rsqlFilterStrategy = RSQLFilterDialect.builder().dictionary(dictionary).build(); MultipleFilterDialect multipleFilterStrategy = new MultipleFilterDialect( Arrays.asList(rsqlFilterStrategy, defaultFilterStrategy), @@ -87,6 +88,8 @@ protected void configure() { .withEntityDictionary(dictionary) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", Calendar.getInstance().getTimeZone()) .build()); + + elide.doScans(); bind(elide).to(Elide.class).named("elide"); } }); @@ -121,7 +124,7 @@ protected DataStoreTestHarness createHarness() { SQLDialectFactory.getDialect(dbConfig.getDialect()))) ); - return new AggregationDataStoreTestHarness(emf, defaultConnectionDetails, connectionDetailsMap, VALIDATOR); + return new NoCacheAggregationDataStoreTestHarness(emf, defaultConnectionDetails, connectionDetailsMap, VALIDATOR); } @Test @@ -166,7 +169,7 @@ public void namespaceTest() { .body("data.attributes.friendlyName", equalTo("Sales")) .body("data.relationships.tables.data.id", contains( "SalesNamespace_orderDetails", - "SalesNamespace_customerDetails", + "SalesNamespace_orderDetails2", "SalesNamespace_deliveryDetails")); given() .accept("application/vnd.api+json") @@ -259,7 +262,9 @@ public void tableTest() { "playerStats.player2Name", "playerStats.countryIsoCode", "playerStats.subCountryIsoCode", - "playerStats.overallRating")) + "playerStats.overallRating", + "playerStats.placeType1", + "playerStats.placeType2")) .body("data.relationships.metrics.data.id", containsInAnyOrder("playerStats.id", "playerStats.lowScore", "playerStats.highScore", "playerStats.dailyAverageScorePerPeriod")) .body("data.relationships.timeDimensions.data.id", containsInAnyOrder("playerStats.recordedDate", @@ -274,6 +279,50 @@ public void tableTest() { containsInAnyOrder("SalesNamespace_orderDetails.denominator")); } + @Test + public void hiddenDimensionTest() { + + //Non Hidden + given() + .accept("application/vnd.api+json") + .get("/table/SalesNamespace_orderDetails/dimensions/SalesNamespace_orderDetails.zipCode") + .then() + .statusCode(HttpStatus.SC_OK); + + //Hidden + given() + .accept("application/vnd.api+json") + .get("/table/SalesNamespace_orderDetails/dimensions/SalesNamespace_orderDetails.zipCodeHidden") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + + //Hidden + given() + .accept("application/vnd.api+json") + .get("/table/SalesNamespace_orderDetails/columns") + .then() + .body("data.id", not(contains("SalesNamespace_orderDetails.zipCodeHidden"))) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void hiddenTableTest() { + + //Non Hidden + given() + .accept("application/vnd.api+json") + .get("/table/SalesNamespace_orderDetails") + .then() + .statusCode(HttpStatus.SC_OK); + + //Hidden + given() + .accept("application/vnd.api+json") + .get("/table/SalesNamespace_performance") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + } + @Test public void dimensionMetaDataTest() { @@ -300,7 +349,7 @@ public void dimensionValuesOnReferenceTest() { .accept("application/vnd.api+json") .get("/table/playerStats/dimensions/playerStats.countryIsoCode") .then() - .body("data.attributes.values", containsInAnyOrder("USA", "HK")) + .body("data.attributes.values", containsInAnyOrder("USA", "HKG")) .body("data.attributes.valueSourceType", equalTo("ENUM")) .body("data.attributes.tableSource", nullValue()) .body("data.attributes.columnType", equalTo("FORMULA")) @@ -314,7 +363,7 @@ public void dimensionValuesOnFieldTest() { .accept("application/vnd.api+json") .get("/table/playerStats/dimensions/playerStats.overallRating") .then() - .body("data.attributes.values", containsInAnyOrder("Good", "OK", "Terrible")) + .body("data.attributes.values", containsInAnyOrder("Good", "OK", "Great", "Terrible")) .body("data.attributes.valueSourceType", equalTo("ENUM")) .body("data.attributes.tableSource", nullValue()) .body("data.attributes.columnType", equalTo("FIELD")) @@ -390,8 +439,8 @@ public void timeDimensionMetaDataTest() { .body("included.attributes.expression", containsInAnyOrder( "PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd')", - "PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM'), 'yyyy-MM')", - "PARSEDATETIME(CONCAT(FORMATDATETIME({{$$column.expr}}, 'yyyy-'), 3 * QUARTER({{$$column.expr}}) - 2), 'yyyy-MM')" + "PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-01'), 'yyyy-MM-dd')", + "PARSEDATETIME(CONCAT(FORMATDATETIME({{$$column.expr}}, 'yyyy-'), LPAD(3 * QUARTER({{$$column.expr}}) - 2, 2, '0'), '-01'), 'yyyy-MM-dd')" )); } @@ -516,4 +565,40 @@ public void dynamicConfigMetaDataTest() { .body("data.attributes.valueSourceType", equalTo("TABLE")) .body("data.relationships.tableSource.data.id", equalTo("regionDetails.region")); } + + @Test + public void testEnumDimensionTypes() { + + given() + .accept("application/vnd.api+json") + .get("/table/SalesNamespace_orderDetails/dimensions/SalesNamespace_orderDetails.customerRegionType1") + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.attributes.name", equalTo("customerRegionType1")) + .body("data.attributes.valueType", equalTo("TEXT")); + + given() + .accept("application/vnd.api+json") + .get("/table/SalesNamespace_orderDetails/dimensions/SalesNamespace_orderDetails.customerRegionType2") + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.attributes.name", equalTo("customerRegionType2")) + .body("data.attributes.valueType", equalTo("TEXT")); + + given() + .accept("application/vnd.api+json") + .get("/table/playerStats/dimensions/playerStats.placeType1") + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.attributes.name", equalTo("placeType1")) + .body("data.attributes.valueType", equalTo("TEXT")); + + given() + .accept("application/vnd.api+json") + .get("/table/playerStats/dimensions/playerStats.placeType2") + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.attributes.name", equalTo("placeType2")) + .body("data.attributes.valueType", equalTo("TEXT")); + } } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/NoCacheAggregationDataStoreIntegrationTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/NoCacheAggregationDataStoreIntegrationTest.java new file mode 100644 index 0000000000..9ed73a38b5 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/NoCacheAggregationDataStoreIntegrationTest.java @@ -0,0 +1,2402 @@ +/* + * Copyright 2020, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.datastores.aggregation.integration; + +import static com.yahoo.elide.test.graphql.GraphQLDSL.argument; +import static com.yahoo.elide.test.graphql.GraphQLDSL.arguments; +import static com.yahoo.elide.test.graphql.GraphQLDSL.document; +import static com.yahoo.elide.test.graphql.GraphQLDSL.field; +import static com.yahoo.elide.test.graphql.GraphQLDSL.mutation; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selection; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selections; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.Mockito.when; +import com.yahoo.elide.core.datastore.test.DataStoreTestHarness; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.datastores.aggregation.AggregationDataStore; +import com.yahoo.elide.datastores.aggregation.framework.NoCacheAggregationDataStoreTestHarness; +import com.yahoo.elide.datastores.aggregation.metadata.enums.TimeGrain; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; +import com.yahoo.elide.test.graphql.elements.Arguments; + +import example.PlayerStats; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import javax.persistence.EntityManagerFactory; +import javax.ws.rs.core.MediaType; + +/** + * Integration tests for {@link AggregationDataStore}. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class NoCacheAggregationDataStoreIntegrationTest extends AggregationDataStoreIntegrationTest { + + public NoCacheAggregationDataStoreIntegrationTest() { + super(); + } + + @Override + protected DataStoreTestHarness createHarness() { + + ConnectionDetails defaultConnectionDetails = createDefaultConnectionDetails(); + + EntityManagerFactory emf = createEntityManagerFactory(); + + Map connectionDetailsMap = createConnectionDetailsMap(defaultConnectionDetails); + + return new NoCacheAggregationDataStoreTestHarness(emf, defaultConnectionDetails, connectionDetailsMap, VALIDATOR); + } + + @Test + public void testGraphQLSchema() throws IOException { + String graphQLRequest = "{" + + "__type(name: \"PlayerStatsWithViewEdge\") {" + + " name " + + " fields {" + + " name " + + " type {" + + " name" + + " fields {" + + " name " + + " type {" + + " name " + + " fields {" + + " name" + + " }" + + " }" + + " }" + + " }" + + " }" + + "}" + + "}"; + + String expected = loadGraphQLResponse("testGraphQLSchema.json"); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testGraphQLMetdata() throws Exception { + String graphQLRequest = document( + selection( + field( + "table", + arguments( + argument("ids", Arrays.asList("playerStatsView")) + ), + selections( + field("name"), + field("arguments", + selections( + field("name"), + field("type"), + field("defaultValue") + ) + + ) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "table", + selections( + field("name", "playerStatsView"), + field("arguments", + selections( + field("name", "rating"), + field("type", "TEXT"), + field("defaultValue", "") + ), + selections( + field("name", "minScore"), + field("type", "INTEGER"), + field("defaultValue", "0") + ) + ) + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testColumnWhichReferencesHiddenDimension() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("orderTotal"), + field("zipCode") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("orderTotal", 78.87), + field("zipCode", 0) + ), + selections( + field("orderTotal", 61.43), + field("zipCode", 10002) + ), + selections( + field("orderTotal", 285.19), + field("zipCode", 20166) + ), + selections( + field("orderTotal", 88.22), + field("zipCode", 20170) + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testHiddenTable() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_performance", + selections( + field("totalSales") + ) + ) + ) + ).toQuery(); + + String errorMessage = "Bad Request Body'Unknown entity {SalesNamespace_performance}.'"; + + runQueryWithExpectedError(graphQLRequest, errorMessage); + } + + @Test + public void testHiddenColumn() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("orderTotal"), + field("zipCodeHidden") + ) + ) + ) + ).toQuery(); + + String errorMessage = "Validation error (FieldUndefined@[SalesNamespace_orderDetails/edges/node/zipCodeHidden]) : Field 'zipCodeHidden' in type 'SalesNamespace_orderDetails' is undefined"; + + runQueryWithExpectedError(graphQLRequest, errorMessage); + } + + @Test + public void parameterizedJsonApiColumnTest() throws Exception { + when() + .get("/SalesNamespace_orderDetails?filter=deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'&fields[SalesNamespace_orderDetails]=orderRatio") + .then() + .body(equalTo( + data( + resource( + type("SalesNamespace_orderDetails"), + id("0"), + attributes( + attr("orderRatio", new BigDecimal("1.0000000000000000000000000000000000000000")) + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void parameterizedGraphQLFilterNoAliasTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"orderRatio[numerator:orderMax][denominator:orderMax]>=.5;deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("orderRatio", "ratio1", arguments( + argument("numerator", "\"orderMax\""), + argument("denominator", "\"orderMax\"") + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("ratio1", 1.0) + ) + ) + ) + ).toResponse(); + + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void parameterizedGraphQLFilterWithAliasTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"ratio1>=.5;deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("orderRatio", "ratio1", arguments( + argument("numerator", "\"orderMax\""), + argument("denominator", "\"orderMax\"") + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("ratio1", 1.0) + ) + ) + ) + ).toResponse(); + + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void parameterizedGraphQLSortWithAliasTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\""), + argument("sort", "\"ratio1\"") + ), + selections( + field("orderRatio", "ratio1", arguments( + argument("numerator", "\"orderMax\""), + argument("denominator", "\"orderMax\"") + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("ratio1", 1.0) + ) + ) + ) + ).toResponse(); + + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void parameterizedGraphQLColumnTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("orderRatio", "ratio1", arguments( + argument("numerator", "\"orderMax\""), + argument("denominator", "\"orderMax\"") + )), + field("orderRatio", "ratio2", arguments( + argument("numerator", "\"orderMax\""), + argument("denominator", "\"orderTotal\"") + )), + field("orderRatio", "ratio3", arguments()) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("ratio1", 1.0), + field("ratio2", 0.20190379786260731), + field("ratio3", 1.0) + ) + ) + ) + ).toResponse(); + + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void basicAggregationTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"highScore\"") + ), + selections( + field("highScore"), + field("overallRating"), + field("countryIsoCode"), + field("playerRank") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("highScore", 1000), + field("overallRating", "Good"), + field("countryIsoCode", "HKG"), + field("playerRank", 3) + ), + selections( + field("highScore", 1234), + field("overallRating", "Good"), + field("countryIsoCode", "USA"), + field("playerRank", 1) + ), + selections( + field("highScore", 3147483647L), + field("overallRating", "Great"), + field("countryIsoCode", "USA"), + field("playerRank", 2) + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void metricFormulaTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "videoGame", + arguments( + argument("sort", "\"timeSpentPerSession\"") + ), + selections( + field("timeSpent"), + field("sessions"), + field("timeSpentPerSession"), + field("playerName") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "videoGame", + selections( + field("timeSpent", 720), + field("sessions", 60), + field("timeSpentPerSession", 12.0), + field("playerName", "Jon Doe") + ), + selections( + field("timeSpent", 350), + field("sessions", 25), + field("timeSpentPerSession", 14.0), + field("playerName", "Jane Doe") + ), + selections( + field("timeSpent", 300), + field("sessions", 10), + field("timeSpentPerSession", 30.0), + field("playerName", "Han") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + + //When admin = false + + when(securityContextMock.isUserInRole("admin.user")).thenReturn(false); + + expected = document( + selections( + field( + "videoGame", + selections( + field("timeSpent", 720), + field("sessions", 60), + field("timeSpentPerSession", 12.0), + field("playerName", "Jon Doe") + ), + selections( + field("timeSpent", 350), + field("sessions", 25), + field("timeSpentPerSession", 14.0), + field("playerName", "Jane Doe") + ) + ) + ) + ).toResponse(); + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Test sql expression in where, sorting, group by and projection. + * @throws Exception exception + */ + @Test + public void dimensionFormulaTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"playerLevel\""), + argument("filter", "\"playerLevel>\\\"0\\\"\"") + ), + selections( + field("highScore"), + field("playerLevel") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("highScore", 1234), + field("playerLevel", 1) + ), + selections( + field("highScore", 3147483647L), + field("playerLevel", 2) + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void noMetricQueryTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStatsWithView", + arguments( + argument("sort", "\"countryViewViewIsoCode\"") + ), + selections( + field("countryViewViewIsoCode") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStatsWithView", + selections( + field("countryViewViewIsoCode", "HKG") + ), + selections( + field("countryViewViewIsoCode", "USA") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void whereFilterTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("filter", "\"overallRating==\\\"Good\\\"\"") + ), + selections( + field("highScore"), + field("overallRating") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("highScore", 1234), + field("overallRating", "Good") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void havingFilterTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("filter", "\"lowScore<\\\"45\\\"\"") + ), + selections( + field("lowScore"), + field("overallRating"), + field("playerName") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("lowScore", 35), + field("overallRating", "Good"), + field("playerName", "Jon Doe") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Test the case that a where clause is promoted into having clause. + * @throws Exception exception + */ + @Test + public void wherePromotionTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("filter", "\"overallRating==\\\"Good\\\",lowScore<\\\"45\\\"\""), + argument("sort", "\"lowScore\"") + ), + selections( + field("lowScore"), + field("overallRating"), + field("playerName") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("lowScore", 35), + field("overallRating", "Good"), + field("playerName", "Jon Doe") + ), + selections( + field("lowScore", 72), + field("overallRating", "Good"), + field("playerName", "Han") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Test the case that a where clause, which requires dimension join, is promoted into having clause. + * @throws Exception exception + */ + @Test + public void havingClauseJoinTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("filter", "\"countryIsoCode==\\\"USA\\\",lowScore<\\\"45\\\"\""), + argument("sort", "\"lowScore\"") + ), + selections( + field("lowScore"), + field("countryIsoCode"), + field("playerName") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("lowScore", 35), + field("countryIsoCode", "USA"), + field("playerName", "Jon Doe") + ), + selections( + field("lowScore", 241), + field("countryIsoCode", "USA"), + field("playerName", "Jane Doe") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Test invalid where promotion on a dimension field that is not grouped. + * @throws Exception exception + */ + @Test + public void ungroupedHavingDimensionTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("filter", "\"countryIsoCode==\\\"USA\\\",lowScore<\\\"45\\\"\"") + ), + selections( + field("lowScore") + ) + ) + ) + ).toQuery(); + + String errorMessage = "Exception while fetching data (/playerStats) : Invalid operation: " + + "Post aggregation filtering on 'countryIsoCode' requires the field to be projected in the response"; + + runQueryWithExpectedError(graphQLRequest, errorMessage); + } + + /** + * Test invalid having clause on a metric field that is not aggregated. + * @throws Exception exception + */ + @Test + public void nonAggregatedHavingMetricTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("filter", "\"highScore>\\\"0\\\"\"") + ), + selections( + field("lowScore") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("lowScore", 35) + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Test invalid where promotion on a different class than the queried class. + * @throws Exception exception + */ + @Test + public void invalidHavingClauseClassTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("filter", "\"country.isoCode==\\\"USA\\\",lowScore<\\\"45\\\"\"") + ), + selections( + field("lowScore") + ) + ) + ) + ).toQuery(); + + String errorMessage = "Exception while fetching data (/playerStats) : Invalid operation: " + + "Relationship traversal not supported for analytic queries."; + + runQueryWithExpectedError(graphQLRequest, errorMessage); + } + + @Test + public void dimensionSortingTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"overallRating\"") + ), + selections( + field("lowScore"), + field("overallRating") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("lowScore", 35), + field("overallRating", "Good") + ), + selections( + field("lowScore", 241), + field("overallRating", "Great") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void metricSortingTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"-highScore\"") + ), + selections( + field("highScore"), + field("countryIsoCode") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("highScore", 3147483647L), + field("countryIsoCode", "USA") + ), + selections( + field("highScore", 1000), + field("countryIsoCode", "HKG") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void multipleColumnsSortingTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"overallRating,playerName\"") + ), + selections( + field("lowScore"), + field("overallRating"), + field("playerName") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("lowScore", 72), + field("overallRating", "Good"), + field("playerName", "Han") + ), + selections( + field("lowScore", 35), + field("overallRating", "Good"), + field("playerName", "Jon Doe") + ), + selections( + field("lowScore", 241), + field("overallRating", "Great"), + field("playerName", "Jane Doe") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void idSortingTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"id\"") + ), + selections( + field("lowScore"), + field("id") + ) + ) + ) + ).toQuery(); + + String expected = "Exception while fetching data (/playerStats) : Invalid operation: Sorting on id field is not permitted"; + + runQueryWithExpectedError(graphQLRequest, expected); + } + + @Test + public void nestedDimensionNotInQuerySortingTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"-countryIsoCode,lowScore\"") + ), + selections( + field("lowScore") + ) + ) + ) + ).toQuery(); + + String expected = "Exception while fetching data (/playerStats) : Invalid operation: Can not sort on countryIsoCode as it is not present in query"; + + runQueryWithExpectedError(graphQLRequest, expected); + } + + @Test + public void sortingOnMetricNotInQueryTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"highScore\"") + ), + selections( + field("lowScore"), + field("countryIsoCode") + ) + ) + ) + ).toQuery(); + + String expected = "Exception while fetching data (/playerStats) : Invalid operation: Can not sort on highScore as it is not present in query"; + + runQueryWithExpectedError(graphQLRequest, expected); + } + + @Test + public void basicViewAggregationTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStatsWithView", + arguments( + argument("sort", "\"highScore\"") + ), + selections( + field("highScore"), + field("countryViewIsoCode") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStatsWithView", + selections( + field("highScore", 1000), + field("countryViewIsoCode", "HKG") + ), + selections( + field("highScore", 3147483647L), + field("countryViewIsoCode", "USA") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void multiTimeDimensionTest() throws IOException { + String graphQLRequest = document( + selection( + field( + "playerStats", + selections( + field("recordedDate"), + field("updatedDate") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("recordedDate", "2019-07-11"), + field("updatedDate", "2020-07-12") + ), + selections( + field("recordedDate", "2019-07-12"), + field("updatedDate", "2019-10-12") + ), + selections( + field("recordedDate", "2019-07-13"), + field("updatedDate", "2020-01-12") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testGraphqlQueryDynamicModelById() throws IOException { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("id"), + field("orderTotal") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("id", "0"), + field("orderTotal", 513.71) + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void jsonApiAggregationTest() { + given() + .accept("application/vnd.api+json") + .get("/playerStats") + .then() + .statusCode(HttpStatus.SC_OK) + .body("data.id", hasItems("0", "1", "2")) + .body("data.attributes.highScore", hasItems(1000, 1234, 3147483647L)) + .body("data.attributes.countryIsoCode", hasItems("USA", "HKG")); + } + + /** + * Below tests demonstrate using the aggregation store from dynamic configuration. + */ + @Test + public void testDynamicAggregationModel() { + String getPath = "/SalesNamespace_orderDetails?sort=customerRegion,orderTime&page[totals]&" + + "fields[SalesNamespace_orderDetails]=orderTotal,customerRegion,orderTime&filter=deliveryTime>=2020-01-01;deliveryTime<2020-12-31;orderTime>=2020-08"; + given() + .when() + .get(getPath) + .then() + .statusCode(HttpStatus.SC_OK) + .body("data", hasSize(4)) + .body("data.id", hasItems("0", "1", "2", "3")) + .body("data.attributes", hasItems( + allOf(hasEntry("customerRegion", "NewYork"), hasEntry("orderTime", "2020-08")), + allOf(hasEntry("customerRegion", "Virginia"), hasEntry("orderTime", "2020-08")), + allOf(hasEntry("customerRegion", "Virginia"), hasEntry("orderTime", "2020-08")), + allOf(hasEntry("customerRegion", null), hasEntry("orderTime", "2020-09")))) + .body("data.attributes.orderTotal", hasItems(78.87F, 61.43F, 113.07F, 260.34F)) + .body("meta.page.number", equalTo(1)) + .body("meta.page.totalRecords", equalTo(4)) + .body("meta.page.totalPages", equalTo(1)) + .body("meta.page.limit", equalTo(500)); + } + + @Test + public void testInvalidSparseFields() { + String expectedError = "Invalid value: SalesNamespace_orderDetails does not contain the fields: [orderValue, customerState]"; + String getPath = "/SalesNamespace_orderDetails?fields[SalesNamespace_orderDetails]=orderValue,customerState,orderTime"; + given() + .when() + .get(getPath) + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body("errors.detail", hasItems(expectedError)); + } + + @Test + public void missingClientFilterTest() { + String expectedError = "Querying SalesNamespace_orderDetails requires a mandatory filter:" + + " deliveryTime>={{start}};deliveryTime<{{end}}"; + when() + .get("/SalesNamespace_orderDetails/") + .then() + .body("errors.detail", hasItems(expectedError)) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + @Test + public void incompleteClientFilterTest() { + String expectedError = "Querying SalesNamespace_orderDetails requires a mandatory filter:" + + " deliveryTime>={{start}};deliveryTime<{{end}}"; + when() + .get("/SalesNamespace_orderDetails?filter=deliveryTime>=2020-08") + .then() + .body("errors.detail", hasItems(expectedError)) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + @Test + public void completeClientFilterTest() { + when() + .get("/SalesNamespace_deliveryDetails?filter=month>=2020-08;month<2020-09") + .then() + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void testGraphQLDynamicAggregationModel() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"customerRegion\""), + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime=='2020-08'\"") + ), + selections( + field("orderTotal"), + field("customerRegion"), + field("customerRegionRegion"), + field("orderTime", arguments( + argument("grain", TimeGrain.MONTH) + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("orderTotal", 61.43F), + field("customerRegion", "NewYork"), + field("customerRegionRegion", "NewYork"), + field("orderTime", "2020-08") + ), + selections( + field("orderTotal", 113.07F), + field("customerRegion", "Virginia"), + field("customerRegionRegion", "Virginia"), + field("orderTime", "2020-08") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Tests for below type of column references. + * + * a) Physical Column Reference in same table. + * b) Logical Column Reference in same table, which references Physical column in same table. + * c) Logical Column Reference in same table, which references another Logical column in same table, which + * references Physical column in same table. + * d) Physical Column Reference in referred table. + * e) Logical Column Reference in referred table, which references Physical column in referred table. + * f) Logical Column Reference in referred table, which references another Logical column in referred table, which + * references another Logical column in referred table, which references Physical column in referred table. + * g) Logical Column Reference in same table, which references Physical column in referred table. + * h) Logical Column Reference in same table, which references another Logical Column in referred table, which + * references another Logical column in referred table, which references another Logical column in referred table, + * which references Physical column in referred table + * + * @throws Exception + */ + @Test + public void testGraphQLDynamicAggregationModelAllFields() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"courierName,deliveryDate,orderTotal,customerRegion\""), + argument("filter", "\"deliveryYear=='2020';(deliveryTime>='2020-08-01';deliveryTime<'2020-12-31');(deliveryDate>='2020-09-01',orderTotal>50)\"") + ), + selections( + field("courierName"), + field("deliveryTime"), + field("deliveryHour"), + field("deliveryDate"), + field("deliveryMonth"), + field("deliveryYear"), + field("deliveryDefault"), + field("orderTime", "bySecond", arguments( + argument("grain", TimeGrain.SECOND) + )), + field("orderTime", "byDay", arguments( + argument("grain", TimeGrain.DAY) + )), + field("orderTime", "byMonth", arguments( + argument("grain", TimeGrain.MONTH) + )), + field("customerRegion"), + field("customerRegionRegion"), + field("orderTotal"), + field("zipCode"), + field("orderId") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("courierName", "FEDEX"), + field("deliveryTime", "2020-09-11T16:30:11"), + field("deliveryHour", "2020-09-11T16"), + field("deliveryDate", "2020-09-11"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-09-08T16:30:11"), + field("deliveryDefault", "2020-09-11"), + field("byDay", "2020-09-08"), + field("byMonth", "2020-09"), + field("customerRegion", "Virginia"), + field("customerRegionRegion", "Virginia"), + field("orderTotal", 84.11F), + field("zipCode", 20166), + field("orderId", "order-1b") + ), + selections( + field("courierName", "FEDEX"), + field("deliveryTime", "2020-09-11T16:30:11"), + field("deliveryHour", "2020-09-11T16"), + field("deliveryDate", "2020-09-11"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-09-08T16:30:11"), + field("deliveryDefault", "2020-09-11"), + field("byDay", "2020-09-08"), + field("byMonth", "2020-09"), + field("customerRegion", "Virginia"), + field("customerRegionRegion", "Virginia"), + field("orderTotal", 97.36F), + field("zipCode", 20166), + field("orderId", "order-1c") + ), + selections( + field("courierName", "UPS"), + field("deliveryTime", "2020-09-05T16:30:11"), + field("deliveryHour", "2020-09-05T16"), + field("deliveryDate", "2020-09-05"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-08-30T16:30:11"), + field("deliveryDefault", "2020-09-05"), + field("byDay", "2020-08-30"), + field("byMonth", "2020-08"), + field("customerRegion", "Virginia"), + field("customerRegionRegion", "Virginia"), + field("orderTotal", 103.72F), + field("zipCode", 20166), + field("orderId", "order-1a") + ), + selections( + field("courierName", "UPS"), + field("deliveryTime", "2020-09-13T16:30:11"), + field("deliveryHour", "2020-09-13T16"), + field("deliveryDate", "2020-09-13"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-09-09T16:30:11"), + field("deliveryDefault", "2020-09-13"), + field("byDay", "2020-09-09"), + field("byMonth", "2020-09"), + field("customerRegion", (String) null, false), + field("customerRegionRegion", (String) null, false), + field("orderTotal", 78.87F), + field("zipCode", 0), + field("orderId", "order-null-enum") + ), + selections( + field("courierName", "UPS"), + field("deliveryTime", "2020-09-13T16:30:11"), + field("deliveryHour", "2020-09-13T16"), + field("deliveryDate", "2020-09-13"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-09-09T16:30:11"), + field("deliveryDefault", "2020-09-13"), + field("byDay", "2020-09-09"), + field("byMonth", "2020-09"), + field("customerRegion", "Virginia"), + field("customerRegionRegion", "Virginia"), + field("orderTotal", 78.87F), + field("zipCode", 20170), + field("orderId", "order-3b") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Verifies tableMaker logic. Duplicates everything query for orderDetails (no maker) on + * orderDetails2 (maker). + * @throws Exception + */ + @Test + public void testTableMaker() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails2", + arguments( + argument("sort", "\"courierName,deliveryDate,orderTotal,customerRegion\""), + argument("filter", "\"deliveryYear=='2020';(deliveryTime>='2020-08-01';deliveryTime<'2020-12-31');(deliveryDate>='2020-09-01',orderTotal>50)\"") + ), + selections( + field("courierName"), + field("deliveryTime"), + field("deliveryHour"), + field("deliveryDate"), + field("deliveryMonth"), + field("deliveryYear"), + field("deliveryDefault"), + field("orderTime", "bySecond", arguments( + argument("grain", TimeGrain.SECOND) + )), + field("orderTime", "byDay", arguments( + argument("grain", TimeGrain.DAY) + )), + field("orderTime", "byMonth", arguments( + argument("grain", TimeGrain.MONTH) + )), + field("customerRegion"), + field("customerRegionRegion"), + field("orderTotal"), + field("zipCode"), + field("orderId") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails2", + selections( + field("courierName", "FEDEX"), + field("deliveryTime", "2020-09-11T16:30:11"), + field("deliveryHour", "2020-09-11T16"), + field("deliveryDate", "2020-09-11"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-09-08T16:30:11"), + field("deliveryDefault", "2020-09-11"), + field("byDay", "2020-09-08"), + field("byMonth", "2020-09"), + field("customerRegion", "Virginia"), + field("customerRegionRegion", "Virginia"), + field("orderTotal", 84.11F), + field("zipCode", 20166), + field("orderId", "order-1b") + ), + selections( + field("courierName", "FEDEX"), + field("deliveryTime", "2020-09-11T16:30:11"), + field("deliveryHour", "2020-09-11T16"), + field("deliveryDate", "2020-09-11"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-09-08T16:30:11"), + field("deliveryDefault", "2020-09-11"), + field("byDay", "2020-09-08"), + field("byMonth", "2020-09"), + field("customerRegion", "Virginia"), + field("customerRegionRegion", "Virginia"), + field("orderTotal", 97.36F), + field("zipCode", 20166), + field("orderId", "order-1c") + ), + selections( + field("courierName", "UPS"), + field("deliveryTime", "2020-09-05T16:30:11"), + field("deliveryHour", "2020-09-05T16"), + field("deliveryDate", "2020-09-05"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-08-30T16:30:11"), + field("deliveryDefault", "2020-09-05"), + field("byDay", "2020-08-30"), + field("byMonth", "2020-08"), + field("customerRegion", "Virginia"), + field("customerRegionRegion", "Virginia"), + field("orderTotal", 103.72F), + field("zipCode", 20166), + field("orderId", "order-1a") + ), + selections( + field("courierName", "UPS"), + field("deliveryTime", "2020-09-13T16:30:11"), + field("deliveryHour", "2020-09-13T16"), + field("deliveryDate", "2020-09-13"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-09-09T16:30:11"), + field("deliveryDefault", "2020-09-13"), + field("byDay", "2020-09-09"), + field("byMonth", "2020-09"), + field("customerRegion", (String) null, false), + field("customerRegionRegion", (String) null, false), + field("orderTotal", 78.87F), + field("zipCode", 0), + field("orderId", "order-null-enum") + ), + selections( + field("courierName", "UPS"), + field("deliveryTime", "2020-09-13T16:30:11"), + field("deliveryHour", "2020-09-13T16"), + field("deliveryDate", "2020-09-13"), + field("deliveryMonth", "2020-09"), + field("deliveryYear", "2020"), + field("bySecond", "2020-09-09T16:30:11"), + field("deliveryDefault", "2020-09-13"), + field("byDay", "2020-09-09"), + field("byMonth", "2020-09"), + field("customerRegion", "Virginia"), + field("customerRegionRegion", "Virginia"), + field("orderTotal", 78.87F), + field("zipCode", 20170), + field("orderId", "order-3b") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testGraphQLDynamicAggregationModelDateTime() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"customerRegion\""), + argument("filter", "\"bySecond=='2020-09-08T16:30:11';(deliveryTime>='2020-01-01';deliveryTime<'2020-12-31')\"") + ), + selections( + field("orderTotal"), + field("customerRegion"), + field("orderTime", "byMonth", arguments( + argument("grain", TimeGrain.MONTH) + )), + field("orderTime", "bySecond", arguments( + argument("grain", TimeGrain.SECOND) + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("orderTotal", 181.47F), + field("customerRegion", "Virginia"), + field("byMonth", "2020-09"), + field("bySecond", "2020-09-08T16:30:11") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testTimeDimMismatchArgs() throws Exception { + + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"customerRegion\""), + argument("filter", "\"orderTime[grain:DAY]=='2020-08',orderTotal>50\"") + ), + selections( + field("orderTotal"), + field("customerRegion"), + field("orderTime", arguments( + argument("grain", TimeGrain.MONTH) // Does not match grain argument in filter + )) + ) + ) + ) + ).toQuery(); + + String expected = "Exception while fetching data (/SalesNamespace_orderDetails) : Invalid operation: Post aggregation filtering on 'orderTime' requires the field to be projected in the response with matching arguments"; + + runQueryWithExpectedError(graphQLRequest, expected); + } + + @Test + public void testTimeDimMismatchArgsWithDefaultSelect() throws Exception { + + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"customerRegion\""), + argument("filter", "\"orderTime[grain:DAY]=='2020-08',orderTotal>50\"") + ), + selections( + field("orderTotal"), + field("customerRegion"), + field("orderTime") //Default Grain for OrderTime is Month. + ) + ) + ) + ).toQuery(); + + String expected = "Exception while fetching data (/SalesNamespace_orderDetails) : Invalid operation: Post aggregation filtering on 'orderTime' requires the field to be projected in the response with matching arguments"; + + + runQueryWithExpectedError(graphQLRequest, expected); + } + + @Test + public void testTimeDimMismatchArgsWithDefaultFilter() throws Exception { + + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"orderTime,customerRegion\""), + argument("filter", "\"(orderTime=='2020-08-01',orderTotal>50);(deliveryTime>='2020-01-01';deliveryTime<'2020-12-31')\"") //No Grain Arg passed, so works based on Alias's argument in Selection. + ), + selections( + field("orderTotal"), + field("customerRegion"), + field("orderTime", arguments( + argument("grain", TimeGrain.DAY) + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("orderTotal", 103.72F), + field("customerRegion", "Virginia"), + field("orderTime", "2020-08-30") + ), + selections( + field("orderTotal", 181.47F), + field("customerRegion", "Virginia"), + field("orderTime", "2020-09-08") + ), + selections( + field("orderTotal", 78.87F), + field("customerRegion", (String) null, false), + field("orderTime", "2020-09-09") + ), + selections( + field("orderTotal", 78.87F), + field("customerRegion", "Virginia"), + field("orderTime", "2020-09-09") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testAdminRole() throws Exception { + + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"customerRegion\""), + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime=='2020-08'\"") + ), + selections( + field("orderTotal"), + field("customerRegion"), + field("orderTime", arguments( + argument("grain", TimeGrain.MONTH) + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selection( + field( + "SalesNamespace_orderDetails", + selections( + field("orderTotal", 61.43F), + field("customerRegion", "NewYork"), + field("orderTime", "2020-08") + ), + selections( + field("orderTotal", 113.07F), + field("customerRegion", "Virginia"), + field("orderTime", "2020-08") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testOperatorRole() throws Exception { + + when(securityContextMock.isUserInRole("admin")).thenReturn(false); + + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"customerRegion\""), + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime=='2020-08'\"") + ), + selections( + field("customerRegion"), + field("orderTime", arguments( + argument("grain", TimeGrain.MONTH) + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selection( + field( + "SalesNamespace_orderDetails", + selections( + field("customerRegion", "NewYork"), + field("orderTime", "2020-08") + ), + selections( + field("customerRegion", "Virginia"), + field("orderTime", "2020-08") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testGuestUserRole() throws Exception { + + when(securityContextMock.isUserInRole("admin")).thenReturn(false); + when(securityContextMock.isUserInRole("operator")).thenReturn(false); + + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"customerRegion\""), + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime=='2020-08'\"") + ), + selections( + field("customerRegion"), + field("orderTime", arguments( + argument("grain", TimeGrain.MONTH) + )) + ) + ) + ) + ).toQuery(); + + String expected = "Exception while fetching data (/SalesNamespace_orderDetails/edges[0]/node/customerRegion) : ReadPermission Denied"; + + runQueryWithExpectedError(graphQLRequest, expected); + } + + @Test + public void testTimeDimensionAliases() throws Exception { + + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("filter", "\"byDay>='2019-07-12'\""), + argument("sort", "\"byDay\"") + ), + selections( + field("highScore"), + field("recordedDate", "byDay", arguments( + argument("grain", TimeGrain.DAY) + )), + field("recordedDate", "byMonth", arguments( + argument("grain", TimeGrain.MONTH) + )), + field("recordedDate", "byQuarter", arguments( + argument("grain", TimeGrain.QUARTER) + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("highScore", 1234), + field("byDay", "2019-07-12"), + field("byMonth", "2019-07"), + field("byQuarter", "2019-07") + ), + selections( + field("highScore", 1000), + field("byDay", "2019-07-13"), + field("byMonth", "2019-07"), + field("byQuarter", "2019-07") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Check if AggregationBeforeJoinOptimizer works with alias + * @throws Exception + */ + @Test + public void testJoinBeforeAggregationWithAlias() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"highScore\"") + ), + selections( + field("highScore"), + field("countryIsoCode", "countryAlias", Arguments.emptyArgument()) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("highScore", 1000), + field("countryAlias", "HKG") + ), + selections( + field("highScore", 3147483647L), + field("countryAlias", "USA") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Check working of alias on simple metrics, 2-pass agg metrics, simple dimensions, join dimension, and date dimension. + * + * Note that Optimizer is not invoked because of 2 pass aggregation metrics. + * @throws Exception + */ + @Test + public void testMetricsAndDimensionsWithAlias() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + selections( + field("highScore", "highScoreAlias", Arguments.emptyArgument()), + field("dailyAverageScorePerPeriod", "avgScoreAlias", Arguments.emptyArgument()), + field("overallRating", "ratingAlias", Arguments.emptyArgument()), + field("countryIsoCode", "countryAlias", Arguments.emptyArgument()), + field("recordedDate", "byDay", arguments( + argument("grain", TimeGrain.DAY) + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("highScoreAlias", 1000), + field("avgScoreAlias", 1000.0), + field("ratingAlias", "Good"), + field("countryAlias", "HKG"), + field("byDay", "2019-07-13") + ), + selections( + field("highScoreAlias", 1234), + field("avgScoreAlias", 1234), + field("ratingAlias", "Good"), + field("countryAlias", "USA"), + field("byDay", "2019-07-12") + ), + selections( + field("highScoreAlias", 3147483647L), + field("avgScoreAlias", 3147483647L), + field("ratingAlias", "Great"), + field("countryAlias", "USA"), + field("byDay", "2019-07-11") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testTimeDimensionArgumentsInFilter() throws Exception { + + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("sort", "\"customerRegion\""), + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';orderTime[grain:day]=='2020-09-08'\"") + ), + selections( + field("customerRegion"), + field("orderTotal"), + field("orderTime", arguments( + argument("grain", TimeGrain.MONTH) + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selection( + field( + "SalesNamespace_orderDetails", + selections( + field("customerRegion", "Virginia"), + field("orderTotal", 181.47F), + field("orderTime", "2020-09") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testSchemaIntrospection() throws Exception { + String graphQLRequest = "{" + + "__schema {" + + " mutationType {" + + " name " + + " fields {" + + " name " + + " args {" + + " name" + + " defaultValue" + + " }" + + " }" + + " }" + + "}" + + "}"; + + String query = toJsonQuery(graphQLRequest, new HashMap<>()); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(query) + .post("/graphQL") + .then() + .statusCode(HttpStatus.SC_OK) + // Verify that the SalesNamespace_orderDetails Model has an argument "denominator". + .body("data.__schema.mutationType.fields.find { it.name == 'SalesNamespace_orderDetails' }.args.name[7] ", equalTo("denominator")); + + graphQLRequest = "{" + + "__type(name: \"SalesNamespace_orderDetails\") {" + + " name" + + " fields {" + + " name " + + " args {" + + " name" + + " defaultValue" + + " }" + + " }" + + "}" + + "}"; + + query = toJsonQuery(graphQLRequest, new HashMap<>()); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(query) + .post("/graphQL") + .then() + .statusCode(HttpStatus.SC_OK) + // Verify that the orderTotal attribute has an argument "precision". + .body("data.__type.fields.find { it.name == 'orderTotal' }.args.name[0]", equalTo("precision")); + } + + @Test + public void testDelete() throws IOException { + String graphQLRequest = mutation( + selection( + field( + "playerStats", + arguments( + argument("op", "DELETE"), + argument("ids", Arrays.asList("0")) + ), + selections( + field("id"), + field("overallRating") + ) + ) + ) + ).toGraphQLSpec(); + + String expected = "Exception while fetching data (/playerStats) : Invalid operation: Filtering by ID is not supported on playerStats"; + + runQueryWithExpectedError(graphQLRequest, expected); + } + + @Test + public void testUpdate() throws IOException { + + PlayerStats playerStats = new PlayerStats(); + playerStats.setId("1"); + playerStats.setHighScore(100); + + String graphQLRequest = mutation( + selection( + field( + "playerStats", + arguments( + argument("op", "UPDATE"), + argument("data", playerStats) + ), + selections( + field("id"), + field("overallRating") + ) + ) + ) + ).toGraphQLSpec(); + + String expected = "Exception while fetching data (/playerStats) : Invalid operation: Filtering by ID is not supported on playerStats"; + + runQueryWithExpectedError(graphQLRequest, expected); + } + + @Test + public void testUpsertWithStaticModel() throws IOException { + + PlayerStats playerStats = new PlayerStats(); + playerStats.setId("1"); + playerStats.setHighScore(100); + + String graphQLRequest = mutation( + selection( + field( + "playerStats", + arguments( + argument("op", "UPSERT"), + argument("data", playerStats) + ), + selections( + field("id"), + field("overallRating") + ) + ) + ) + ).toGraphQLSpec(); + + String expected = "Exception while fetching data (/playerStats) : Invalid operation: Filtering by ID is not supported on playerStats"; + + runQueryWithExpectedError(graphQLRequest, expected); + } + + @Test + public void testUpsertWithDynamicModel() throws IOException { + + Map order = new HashMap<>(); + order.put("orderId", "1"); + order.put("courierName", "foo"); + + String graphQLRequest = mutation( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("op", "UPSERT"), + argument("data", order) + ), + selections( + field("orderId") + ) + ) + ) + ).toGraphQLSpec(); + + String expected = "Invalid operation: SalesNamespace_orderDetails is read only."; + + runQueryWithExpectedError(graphQLRequest, expected); + } + + /** + * Test missing required column filter on deliveryYear. + * @throws Exception exception + */ + @Test + public void missingRequiredColumnFilter() throws Exception { + String graphQLRequest = document( + selection( + + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("orderTotal"), + field("deliveryYear") + ) + ) + ) + ).toQuery(); + + String errorMessage = "Exception while fetching data (/SalesNamespace_orderDetails) : " + + "Querying deliveryYear requires a mandatory filter: deliveryYear=={{deliveryYear}}"; + + runQueryWithExpectedError(graphQLRequest, errorMessage); + } + + //Security + @Test + public void testPermissionFilters() throws IOException { + when(securityContextMock.isUserInRole("admin.user")).thenReturn(false); + + String graphQLRequest = document( + selection( + field( + "videoGame", + arguments( + argument("sort", "\"timeSpentPerSession\"") + ), + selections( + field("timeSpent"), + field("sessions"), + field("timeSpentPerSession") + ) + ) + ) + ).toQuery(); + + //Records for Jon Doe and Jane Doe will only be aggregated. + String expected = document( + selections( + field( + "videoGame", + selections( + field("timeSpent", 1070), + field("sessions", 85), + field("timeSpentPerSession", 12.588235) + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + + } + + @Test + public void testFieldPermissions() throws IOException { + when(securityContextMock.isUserInRole("operator")).thenReturn(false); + String graphQLRequest = document( + selection( + field( + "videoGame", + selections( + field("timeSpent"), + field("sessions"), + field("timeSpentPerSession"), + field("timeSpentPerGame") + ) + ) + ) + ).toQuery(); + + String expected = "Exception while fetching data (/videoGame/edges[0]/node/timeSpentPerGame) : ReadPermission Denied"; + + runQueryWithExpectedError(graphQLRequest, expected); + + } + + @Test + public void testEnumDimension() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("customerRegionType1"), + field("customerRegionType2") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("customerRegionType1", (String) null, false), + field("customerRegionType2", (String) null, false) + ), + selections( + field("customerRegionType1", "STATE"), + field("customerRegionType2", "STATE") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testHjsonFilterByEnumDimension() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31';customerRegionType1==STATE;customerRegionType2==STATE\"") + ), + selections( + field("customerRegionType1"), + field("customerRegionType2") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("customerRegionType1", "STATE"), + field("customerRegionType2", "STATE") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testJavaFilterByEnumDimension() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("filter", "\"placeType1==STATE;placeType2==STATE\"") + ), + selections( + field("placeType1"), + field("placeType2") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("placeType1", "STATE"), + field("placeType2", "STATE") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testJavaSortByEnumDimension() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"placeType1,placeType2\"") + ), + selections( + field("placeType1"), + field("placeType2") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("placeType1", "STATE"), + field("placeType2", "STATE") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void testHjsonSortByEnumDimension() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\""), + argument("sort", "\"customerRegionType1,customerRegionType2\"") + ), + selections( + field("customerRegionType1"), + field("customerRegionType2") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("customerRegionType1", (String) null, false), + field("customerRegionType2", (String) null, false) + ), + selections( + field("customerRegionType1", "STATE"), + field("customerRegionType2", "STATE") + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/RedisAggregationDataStoreIntegrationTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/RedisAggregationDataStoreIntegrationTest.java new file mode 100644 index 0000000000..352fcdd427 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/integration/RedisAggregationDataStoreIntegrationTest.java @@ -0,0 +1,286 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.datastores.aggregation.integration; + +import static com.yahoo.elide.test.graphql.GraphQLDSL.argument; +import static com.yahoo.elide.test.graphql.GraphQLDSL.arguments; +import static com.yahoo.elide.test.graphql.GraphQLDSL.document; +import static com.yahoo.elide.test.graphql.GraphQLDSL.field; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selection; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selections; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasSize; + +import com.yahoo.elide.core.datastore.test.DataStoreTestHarness; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.datastores.aggregation.AggregationDataStore; +import com.yahoo.elide.datastores.aggregation.framework.RedisAggregationDataStoreTestHarness; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import redis.embedded.RedisServer; + +import java.io.IOException; +import java.math.BigDecimal; +import java.util.Map; +import javax.persistence.EntityManagerFactory; + +/** + * Integration tests for {@link AggregationDataStore} using Redis for cache. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class RedisAggregationDataStoreIntegrationTest extends AggregationDataStoreIntegrationTest { + private static final int PORT = 6379; + + private RedisServer redisServer; + + public RedisAggregationDataStoreIntegrationTest() { + super(); + } + + @BeforeAll + public void beforeAll() { + super.beforeAll(); + try { + redisServer = new RedisServer(PORT); + redisServer.start(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @AfterAll + public void afterEverything() { + try { + redisServer.stop(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + @Override + protected DataStoreTestHarness createHarness() { + + ConnectionDetails defaultConnectionDetails = createDefaultConnectionDetails(); + + EntityManagerFactory emf = createEntityManagerFactory(); + + Map connectionDetailsMap = createConnectionDetailsMap(defaultConnectionDetails); + + return new RedisAggregationDataStoreTestHarness(emf, defaultConnectionDetails, connectionDetailsMap, VALIDATOR); + } + + @Test + public void parameterizedJsonApiColumnTest() throws Exception { + when() + .get("/SalesNamespace_orderDetails?filter=deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'&fields[SalesNamespace_orderDetails]=orderRatio") + .then() + .body(equalTo( + data( + resource( + type("SalesNamespace_orderDetails"), + id("0"), + attributes( + attr("orderRatio", new BigDecimal("1.0000000000000000000000000000000000000000")) + ) + ) + ).toJSON()) + ) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void parameterizedGraphQLFilterNoAliasTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"orderRatio[numerator:orderMax][denominator:orderMax]>=.5;deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("orderRatio", "ratio1", arguments( + argument("numerator", "\"orderMax\""), + argument("denominator", "\"orderMax\"") + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("ratio1", 1.0) + ) + ) + ) + ).toResponse(); + + + runQueryWithExpectedResult(graphQLRequest, expected); + + // Call the Query Again to hit the cache to retrieve the results + runQueryWithExpectedResult(graphQLRequest, expected); + } + + @Test + public void parameterizedGraphQLFilterWithAliasTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "SalesNamespace_orderDetails", + arguments( + argument("filter", "\"ratio1>=.5;deliveryTime>='2020-01-01';deliveryTime<'2020-12-31'\"") + ), + selections( + field("orderRatio", "ratio1", arguments( + argument("numerator", "\"orderMax\""), + argument("denominator", "\"orderMax\"") + )) + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "SalesNamespace_orderDetails", + selections( + field("ratio1", 1.0) + ) + ) + ) + ).toResponse(); + + + runQueryWithExpectedResult(graphQLRequest, expected); + + // Call the Query Again to hit the cache to retrieve the results + runQueryWithExpectedResult(graphQLRequest, expected); + } + + // Use Non Dynamic Model for caching + @Test + public void basicAggregationTest() throws Exception { + String graphQLRequest = document( + selection( + field( + "playerStats", + arguments( + argument("sort", "\"highScore\"") + ), + selections( + field("highScore"), + field("overallRating"), + field("countryIsoCode"), + field("playerRank") + ) + ) + ) + ).toQuery(); + + String expected = document( + selections( + field( + "playerStats", + selections( + field("highScore", 1000), + field("overallRating", "Good"), + field("countryIsoCode", "HKG"), + field("playerRank", 3) + ), + selections( + field("highScore", 1234), + field("overallRating", "Good"), + field("countryIsoCode", "USA"), + field("playerRank", 1) + ), + selections( + field("highScore", 3147483647L), + field("overallRating", "Great"), + field("countryIsoCode", "USA"), + field("playerRank", 2) + ) + ) + ) + ).toResponse(); + + runQueryWithExpectedResult(graphQLRequest, expected); + + // Call the Query Again to hit the cache to retrieve the results + runQueryWithExpectedResult(graphQLRequest, expected); + } + + /** + * Below tests demonstrate using the aggregation store from dynamic configuration through JSON API. + */ + @Test + public void testDynamicAggregationModel() { + String getPath = "/SalesNamespace_orderDetails?sort=customerRegion,orderTime&page[totals]&" + + "fields[SalesNamespace_orderDetails]=orderTotal,customerRegion,orderTime&filter=deliveryTime>=2020-01-01;deliveryTime<2020-12-31;orderTime>=2020-08"; + given() + .when() + .get(getPath) + .then() + .statusCode(HttpStatus.SC_OK) + .body("data", hasSize(4)) + .body("data.id", hasItems("0", "1", "2", "3")) + .body("data.attributes", hasItems( + allOf(hasEntry("customerRegion", "NewYork"), hasEntry("orderTime", "2020-08")), + allOf(hasEntry("customerRegion", "Virginia"), hasEntry("orderTime", "2020-08")), + allOf(hasEntry("customerRegion", "Virginia"), hasEntry("orderTime", "2020-08")), + allOf(hasEntry("customerRegion", null), hasEntry("orderTime", "2020-09")))) + .body("data.attributes.orderTotal", hasItems(78.87F, 61.43F, 113.07F, 260.34F)) + .body("meta.page.number", equalTo(1)) + .body("meta.page.totalRecords", equalTo(4)) + .body("meta.page.totalPages", equalTo(1)) + .body("meta.page.limit", equalTo(500)); + + // Run the query again to hit the cache. + given() + .when() + .get(getPath) + .then() + .statusCode(HttpStatus.SC_OK) + .body("data", hasSize(4)) + .body("data.id", hasItems("0", "1", "2", "3")) + .body("data.attributes", hasItems( + allOf(hasEntry("customerRegion", "NewYork"), hasEntry("orderTime", "2020-08")), + allOf(hasEntry("customerRegion", "Virginia"), hasEntry("orderTime", "2020-08")), + allOf(hasEntry("customerRegion", "Virginia"), hasEntry("orderTime", "2020-08")), + allOf(hasEntry("customerRegion", null), hasEntry("orderTime", "2020-09")))) + .body("data.attributes.orderTotal", hasItems(78.87F, 61.43F, 113.07F, 260.34F)) + .body("meta.page.number", equalTo(1)) + .body("meta.page.totalRecords", equalTo(4)) + .body("meta.page.totalPages", equalTo(1)) + .body("meta.page.limit", equalTo(500)); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/ColumnContextTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/ColumnContextTest.java index 1b7a6577d9..473f4866c2 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/ColumnContextTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/ColumnContextTest.java @@ -61,7 +61,8 @@ public ColumnContextTest() { DataSource mockDataSource = mock(DataSource.class); // The query engine populates the metadata store with actual tables. - new SQLQueryEngine(metaDataStore, new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); + new SQLQueryEngine(metaDataStore, (unused) -> + new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); revenueFactTable = metaDataStore.getTable(ClassType.of(RevenueFact.class)); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTest.java index 46356a5e9d..397d6de98e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/MetaDataStoreTest.java @@ -6,11 +6,25 @@ package com.yahoo.elide.datastores.aggregation.metadata; import static com.yahoo.elide.core.utils.TypeHelper.getClassType; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; import com.yahoo.elide.core.utils.ClassScanner; import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.datastores.aggregation.QueryEngine; +import com.yahoo.elide.datastores.aggregation.metadata.models.Dimension; +import com.yahoo.elide.datastores.aggregation.metadata.models.Metric; +import com.yahoo.elide.datastores.aggregation.metadata.models.Namespace; +import com.yahoo.elide.datastores.aggregation.metadata.models.Table; +import com.yahoo.elide.datastores.aggregation.metadata.models.TimeDimension; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.SQLQueryEngine; import example.Player; +import example.PlayerRanking; import example.PlayerStats; import example.PlayerStatsView; import example.PlayerStatsWithView; @@ -18,31 +32,99 @@ import example.dimensions.CountryView; import example.dimensions.CountryViewNested; import example.dimensions.SubCountry; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import java.util.Set; + public class MetaDataStoreTest { - private static ClassScanner scanner = DefaultClassScanner.getInstance(); - private static MetaDataStore dataStore = new MetaDataStore(scanner, - getClassType(scanner.getAllClasses("example")), true); + private ClassScanner scanner = DefaultClassScanner.getInstance(); + private MetaDataStore dataStore; + public MetaDataStoreTest() { + Set> types = getClassType(Set.of( + PlayerStatsWithView.class, + PlayerStatsView.class, + PlayerStats.class, + PlayerRanking.class, + Country.class, + SubCountry.class, + Player.class, + CountryView.class, + CountryViewNested.class + )); + + dataStore = new MetaDataStore(scanner, types, true); - @BeforeAll - public static void setup() { EntityDictionary dictionary = EntityDictionary.builder().build(); - dictionary.bindEntity(PlayerStatsWithView.class); - dictionary.bindEntity(PlayerStatsView.class); - dictionary.bindEntity(PlayerStats.class); - dictionary.bindEntity(Country.class); - dictionary.bindEntity(SubCountry.class); - dictionary.bindEntity(Player.class); - dictionary.bindEntity(CountryView.class); - dictionary.bindEntity(CountryViewNested.class); + for (Type type: types) { + dictionary.bindEntity(type); + } dataStore.populateEntityDictionary(dictionary); + + ConnectionDetails connectionDetails = mock(ConnectionDetails.class); + QueryEngine engine = new SQLQueryEngine(dataStore, (unused) -> connectionDetails); } @Test public void testSetup() { assertNotNull(dataStore); } + + @Test + public void testHiddenFields() { + Table playerStats = dataStore.getTable(ClassType.of(PlayerStats.class)); + Dimension country = playerStats.getDimension("country"); + Dimension playerRank = playerStats.getDimension("playerRank"); + Metric highScore = playerStats.getMetric("highScore"); + Metric hiddenHighScore = playerStats.getMetric("hiddenHighScore"); + TimeDimension recordedDate = playerStats.getTimeDimension("recordedDate"); + TimeDimension hiddenRecordedDate = playerStats.getTimeDimension("hiddenRecordedDate"); + + assertTrue(country.isHidden()); + assertFalse(playerRank.isHidden()); + assertTrue(hiddenHighScore.isHidden()); + assertFalse(highScore.isHidden()); + assertTrue(hiddenRecordedDate.isHidden()); + assertFalse(recordedDate.isHidden()); + + assertTrue(playerStats.getColumns().contains(highScore)); + assertTrue(playerStats.getColumns().contains(recordedDate)); + assertTrue(playerStats.getColumns().contains(playerRank)); + assertFalse(playerStats.getColumns().contains(country)); + assertFalse(playerStats.getColumns().contains(hiddenHighScore)); + assertFalse(playerStats.getColumns().contains(hiddenRecordedDate)); + + assertTrue(playerStats.getAllColumns().contains(highScore)); + assertTrue(playerStats.getAllColumns().contains(recordedDate)); + assertTrue(playerStats.getAllColumns().contains(playerRank)); + assertTrue(playerStats.getAllColumns().contains(country)); + assertTrue(playerStats.getAllColumns().contains(hiddenHighScore)); + assertTrue(playerStats.getAllColumns().contains(hiddenRecordedDate)); + + assertFalse(playerStats.getDimensions().contains(country)); + assertFalse(playerStats.getMetrics().contains(hiddenHighScore)); + assertFalse(playerStats.getTimeDimensions().contains(hiddenRecordedDate)); + assertTrue(playerStats.getMetrics().contains(highScore)); + assertTrue(playerStats.getDimensions().contains(playerRank)); + assertTrue(playerStats.getTimeDimensions().contains(recordedDate)); + + assertTrue(playerStats.getAllDimensions().contains(country)); + assertTrue(playerStats.getAllMetrics().contains(hiddenHighScore)); + assertTrue(playerStats.getAllTimeDimensions().contains(hiddenRecordedDate)); + assertTrue(playerStats.getAllMetrics().contains(highScore)); + assertTrue(playerStats.getAllDimensions().contains(playerRank)); + assertTrue(playerStats.getAllTimeDimensions().contains(recordedDate)); + } + + @Test + public void testHiddenTable() { + Table player = dataStore.getTable(ClassType.of(Player.class)); + Table playerStats = dataStore.getTable(ClassType.of(PlayerStats.class)); + assertTrue(player.isHidden()); + assertFalse(playerStats.isHidden()); + + Namespace namespace = dataStore.getNamespace(ClassType.of(Player.class)); + assertTrue(namespace.getTables().contains(playerStats)); + assertFalse(namespace.getTables().contains(player)); + } } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/PhysicalRefColumnContextTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/PhysicalRefColumnContextTest.java index 7a2122319a..c0bc1806b4 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/PhysicalRefColumnContextTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/PhysicalRefColumnContextTest.java @@ -48,7 +48,8 @@ public PhysicalRefColumnContextTest() { DataSource mockDataSource = mock(DataSource.class); // The query engine populates the metadata store with actual tables. - new SQLQueryEngine(metaDataStore, new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); + new SQLQueryEngine(metaDataStore, (unused) -> + new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); revenueFactTable = metaDataStore.getTable(ClassType.of(RevenueFact.class)); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/enums/ValueTypeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/enums/ValueTypeTest.java index d5900888db..68883de1f0 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/enums/ValueTypeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/metadata/enums/ValueTypeTest.java @@ -34,6 +34,9 @@ public void testValidTimeValues() { public void testInvalidTimeValues() { assertFalse(ValueType.TIME.matches("foo")); assertFalse(ValueType.TIME.matches("2017;")); + assertFalse(ValueType.COORDINATE.matches("DROP TABLE")); + //SQL Comment + assertFalse(ValueType.ID.matches("--")); } @Test @@ -63,6 +66,9 @@ public void testInvalidDecimalValues() { assertFalse(ValueType.DECIMAL.matches("foo")); assertFalse(ValueType.DECIMAL.matches("01.00.00")); assertFalse(ValueType.DECIMAL.matches("1;")); + assertFalse(ValueType.COORDINATE.matches("DROP TABLE")); + //SQL Comment + assertFalse(ValueType.ID.matches("--")); } @Test @@ -91,6 +97,9 @@ public void testInvalidCoordinateValues() { assertFalse(ValueType.COORDINATE.matches("+1.1")); assertFalse(ValueType.COORDINATE.matches("1.1, 1.1, 1.1")); assertFalse(ValueType.COORDINATE.matches("FOO")); + assertFalse(ValueType.COORDINATE.matches("DROP TABLE")); + //SQL Comment + assertFalse(ValueType.ID.matches("--")); } @Test @@ -98,6 +107,9 @@ public void testInvalidMoneyValues() { assertFalse(ValueType.MONEY.matches("foo")); assertFalse(ValueType.MONEY.matches("01.00.00")); assertFalse(ValueType.MONEY.matches("1;")); + assertFalse(ValueType.COORDINATE.matches("DROP TABLE")); + //SQL Comment + assertFalse(ValueType.ID.matches("--")); } @Test @@ -139,6 +151,9 @@ public void testInvalidNumberValues() { assertFalse(ValueType.INTEGER.matches(".00")); assertFalse(ValueType.INTEGER.matches("1.")); assertFalse(ValueType.INTEGER.matches("1;")); + assertFalse(ValueType.COORDINATE.matches("DROP TABLE")); + //SQL Comment + assertFalse(ValueType.ID.matches("--")); } @Test @@ -158,6 +173,9 @@ public void testInvalidBooleanValues() { assertFalse(ValueType.BOOLEAN.matches(".00")); assertFalse(ValueType.BOOLEAN.matches("1.")); assertFalse(ValueType.BOOLEAN.matches("1;")); + assertFalse(ValueType.COORDINATE.matches("DROP TABLE")); + //SQL Comment + assertFalse(ValueType.ID.matches("--")); } @Test @@ -178,6 +196,8 @@ public void testInvalidTextValues() { assertFalse(ValueType.TEXT.matches("1.")); assertFalse(ValueType.TEXT.matches("DROP TABLE")); assertFalse(ValueType.TEXT.matches("1;")); + //SQL Comment + assertFalse(ValueType.ID.matches("--")); } @Test @@ -198,5 +218,7 @@ public void testInvalidIdValues() { assertFalse(ValueType.ID.matches("1.")); assertFalse(ValueType.ID.matches("DROP TABLE")); assertFalse(ValueType.ID.matches("1;")); + //SQL Comment + assertFalse(ValueType.ID.matches("--")); } } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/query/DefaultQueryPlanMergerTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/query/DefaultQueryPlanMergerTest.java new file mode 100644 index 0000000000..62bc9cc00f --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/query/DefaultQueryPlanMergerTest.java @@ -0,0 +1,385 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.aggregation.query; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.request.Argument; +import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class DefaultQueryPlanMergerTest { + + @Test + public void testCannotMergeNoNesting() { + MetaDataStore metaDataStore = mock(MetaDataStore.class); + DefaultQueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + QueryPlan a = mock(QueryPlan.class); + QueryPlan b = mock(QueryPlan.class); + + when(a.canNest(any())).thenReturn(false); + when(b.canNest(any())).thenReturn(true); + when(a.nestDepth()).thenReturn(1); + when(b.nestDepth()).thenReturn(2); + + assertFalse(merger.canMerge(a, b)); + assertFalse(merger.canMerge(b, a)); + } + + @Test + public void testCannotMergeMismatchedUnnestedWhere() { + MetaDataStore metaDataStore = mock(MetaDataStore.class); + DefaultQueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + QueryPlan a = mock(QueryPlan.class); + QueryPlan b = mock(QueryPlan.class); + + when(a.canNest(any())).thenReturn(false); + when(b.canNest(any())).thenReturn(false); + when(a.nestDepth()).thenReturn(1); + when(b.nestDepth()).thenReturn(1); + + FilterExpression expression = mock(FilterExpression.class); + + when(a.getWhereFilter()).thenReturn(expression); + when(b.getWhereFilter()).thenReturn(null); + + assertFalse(merger.canMerge(a, b)); + assertFalse(merger.canMerge(b, a)); + } + + @Test + public void testCannotMergeMismatchedNestedWhere() { + Queryable source = mock(Queryable.class); + + //A root source. + when(source.getSource()).thenReturn(source); + + MetricProjection m1 = mock(MetricProjection.class); + MetricProjection m2 = mock(MetricProjection.class); + when(m1.getName()).thenReturn("m1"); + when(m2.getName()).thenReturn("m2"); + when(m1.canNest(any(), any())).thenReturn(true); + when(m1.nest(any(), any(), anyBoolean())).thenReturn(Pair.of(m1, Set.of(m1))); + when(m2.canNest(any(), any())).thenReturn(true); + when(m2.nest(any(), any(), anyBoolean())).thenReturn(Pair.of(m2, Set.of(m2))); + + FilterExpression filterA = mock(FilterExpression.class); + FilterExpression filterB = mock(FilterExpression.class); + + QueryPlan a = QueryPlan + .builder() + .source(source) + .whereFilter(filterA) + .metricProjection(m1) + .build(); + + QueryPlan nested = QueryPlan + .builder() + .source(source) + .whereFilter(filterB) + .metricProjection(m2) + .build(); + + QueryPlan b = QueryPlan + .builder() + .source(nested) + .metricProjection(m2) + .build(); + + MetaDataStore metaDataStore = mock(MetaDataStore.class); + DefaultQueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + + assertFalse(merger.canMerge(a, b)); + assertFalse(merger.canMerge(b, a)); + } + + @Test + public void testCannotMergeMismatchedDimension() { + MetaDataStore metaDataStore = mock(MetaDataStore.class); + DefaultQueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + QueryPlan a = mock(QueryPlan.class); + QueryPlan b = mock(QueryPlan.class); + + when(a.canNest(any())).thenReturn(false); + when(b.canNest(any())).thenReturn(false); + when(a.nestDepth()).thenReturn(1); + when(b.nestDepth()).thenReturn(1); + + DimensionProjection p1 = mock(DimensionProjection.class); + Map args1 = new HashMap<>(); + when(p1.getName()).thenReturn("name"); + when(p1.getArguments()).thenReturn(args1); + + DimensionProjection p2 = mock(DimensionProjection.class); + Map args2 = new HashMap<>(); + args2.put("foo", Argument.builder().name("a").value(100).build()); + when(p2.getName()).thenReturn("name"); + when(p2.getArguments()).thenReturn(args2); + + when(a.getDimensionProjections()).thenReturn(List.of(p1)); + when(b.getDimensionProjections()).thenReturn(List.of(p2)); + when(b.getDimensionProjection(eq("name"))).thenReturn(p2); + when(a.getDimensionProjection(eq("name"))).thenReturn(p1); + + assertFalse(merger.canMerge(a, b)); + assertFalse(merger.canMerge(b, a)); + } + + @Test + public void testCannotMergeMismatchedTimeDimension() { + MetaDataStore metaDataStore = mock(MetaDataStore.class); + DefaultQueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + QueryPlan a = mock(QueryPlan.class); + QueryPlan b = mock(QueryPlan.class); + + when(a.canNest(any())).thenReturn(false); + when(b.canNest(any())).thenReturn(false); + when(a.nestDepth()).thenReturn(1); + when(b.nestDepth()).thenReturn(1); + + TimeDimensionProjection p1 = mock(TimeDimensionProjection.class); + Map args1 = new HashMap<>(); + when(p1.getName()).thenReturn("name"); + when(p1.getArguments()).thenReturn(args1); + + TimeDimensionProjection p2 = mock(TimeDimensionProjection.class); + Map args2 = new HashMap<>(); + args2.put("foo", Argument.builder().name("a").value(100).build()); + when(p2.getName()).thenReturn("name"); + when(p2.getArguments()).thenReturn(args2); + + when(a.getTimeDimensionProjections()).thenReturn(List.of(p1)); + when(b.getTimeDimensionProjections()).thenReturn(List.of(p2)); + when(b.getTimeDimensionProjection(eq("name"))).thenReturn(p2); + when(a.getTimeDimensionProjection(eq("name"))).thenReturn(p1); + + assertFalse(merger.canMerge(a, b)); + assertFalse(merger.canMerge(b, a)); + } + + @Test + public void testCanMerge() { + MetaDataStore metaDataStore = mock(MetaDataStore.class); + DefaultQueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + QueryPlan a = mock(QueryPlan.class); + QueryPlan b = mock(QueryPlan.class); + + when(a.canNest(any())).thenReturn(false); + when(b.canNest(any())).thenReturn(false); + when(a.nestDepth()).thenReturn(1); + when(b.nestDepth()).thenReturn(1); + + FilterExpression expression = mock(FilterExpression.class); + when(a.getWhereFilter()).thenReturn(expression); + when(b.getWhereFilter()).thenReturn(expression); + + TimeDimensionProjection p1 = mock(TimeDimensionProjection.class); + Map args1 = new HashMap<>(); + args1.put("foo", Argument.builder().name("a").value(100).build()); + when(p1.getName()).thenReturn("name"); + when(p1.getArguments()).thenReturn(args1); + + TimeDimensionProjection p2 = mock(TimeDimensionProjection.class); + Map args2 = new HashMap<>(); + args2.put("foo", Argument.builder().name("a").value(100).build()); + when(p2.getName()).thenReturn("name"); + when(p2.getArguments()).thenReturn(args2); + + when(a.getTimeDimensionProjections()).thenReturn(List.of(p1)); + when(b.getTimeDimensionProjections()).thenReturn(List.of(p2)); + when(b.getTimeDimensionProjection(eq("name"))).thenReturn(p2); + when(a.getTimeDimensionProjection(eq("name"))).thenReturn(p1); + + assertTrue(merger.canMerge(a, b)); + assertTrue(merger.canMerge(b, a)); + } + + @Test + public void testSimpleMerge() { + Queryable source = mock(Queryable.class); + + //A root source. + when(source.getSource()).thenReturn(source); + + MetricProjection m1 = mock(MetricProjection.class); + MetricProjection m2 = mock(MetricProjection.class); + when(m1.getName()).thenReturn("m1"); + when(m2.getName()).thenReturn("m2"); + + DimensionProjection d1 = mock(DimensionProjection.class); + DimensionProjection d2 = mock(DimensionProjection.class); + when(d1.getName()).thenReturn("d1"); + when(d2.getName()).thenReturn("d2"); + + TimeDimensionProjection t1 = mock(TimeDimensionProjection.class); + TimeDimensionProjection t2 = mock(TimeDimensionProjection.class); + when(t1.getName()).thenReturn("t1"); + when(t2.getName()).thenReturn("t2"); + + QueryPlan a = QueryPlan + .builder() + .source(source) + .metricProjection(m1) + .dimensionProjection(d1) + .timeDimensionProjection(t1) + .build(); + + QueryPlan b = QueryPlan + .builder() + .source(source) + .metricProjection(m2) + .dimensionProjection(d2) + .timeDimensionProjection(t2) + .build(); + + MetaDataStore metaDataStore = mock(MetaDataStore.class); + DefaultQueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + + QueryPlan c = merger.merge(a, b); + assertEquals(List.of(m2, m1), c.getMetricProjections()); + assertEquals(List.of(d2, d1), c.getDimensionProjections()); + assertEquals(List.of(t2, t1), c.getTimeDimensionProjections()); + } + + @Test + public void testNestedMerge() { + Queryable source = mock(Queryable.class); + + //A root source. + when(source.getSource()).thenReturn(source); + + MetricProjection m1 = mock(MetricProjection.class); + MetricProjection m2 = mock(MetricProjection.class); + when(m1.getName()).thenReturn("m1"); + when(m2.getName()).thenReturn("m2"); + when(m1.canNest(any(), any())).thenReturn(true); + when(m1.nest(any(), any(), anyBoolean())).thenReturn(Pair.of(m1, Set.of(m1))); + when(m2.canNest(any(), any())).thenReturn(true); + when(m2.nest(any(), any(), anyBoolean())).thenReturn(Pair.of(m2, Set.of(m2))); + + DimensionProjection d1 = mock(DimensionProjection.class); + DimensionProjection d2 = mock(DimensionProjection.class); + when(d1.getName()).thenReturn("d1"); + when(d2.getName()).thenReturn("d2"); + when(d1.canNest(any(), any())).thenReturn(true); + when(d1.nest(any(), any(), anyBoolean())).thenReturn(Pair.of(d1, Set.of(d1))); + when(d2.canNest(any(), any())).thenReturn(true); + when(d2.nest(any(), any(), anyBoolean())).thenReturn(Pair.of(d2, Set.of(d2))); + + TimeDimensionProjection t1 = mock(TimeDimensionProjection.class); + TimeDimensionProjection t2 = mock(TimeDimensionProjection.class); + when(t1.getName()).thenReturn("t1"); + when(t2.getName()).thenReturn("t2"); + when(t1.canNest(any(), any())).thenReturn(true); + when(t1.nest(any(), any(), anyBoolean())).thenReturn(Pair.of(t1, Set.of(t1))); + when(t2.canNest(any(), any())).thenReturn(true); + when(t2.nest(any(), any(), anyBoolean())).thenReturn(Pair.of(t2, Set.of(t2))); + + FilterExpression filterExpression = mock(FilterExpression.class); + + QueryPlan a = QueryPlan + .builder() + .source(source) + .whereFilter(filterExpression) + .metricProjection(m1) + .dimensionProjection(d1) + .timeDimensionProjection(t1) + .build(); + + QueryPlan nested = QueryPlan + .builder() + .source(source) + .metricProjection(m2) + .dimensionProjection(d2) + .timeDimensionProjection(t2) + .build(); + + QueryPlan b = QueryPlan + .builder() + .source(nested) + .metricProjection(m2) + .dimensionProjection(d2) + .timeDimensionProjection(t2) + .build(); + + MetaDataStore metaDataStore = mock(MetaDataStore.class); + DefaultQueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + + QueryPlan c = merger.merge(a, b); + assertNull(c.getWhereFilter()); + assertEquals(List.of(m2, m1), c.getMetricProjections()); + assertEquals(List.of(d2, d1), c.getDimensionProjections()); + assertEquals(List.of(t2, t1), c.getTimeDimensionProjections()); + + QueryPlan d = (QueryPlan) c.getSource(); + assertEquals(List.of(m2, m1), d.getMetricProjections()); + assertEquals(List.of(d2, d1), d.getDimensionProjections()); + assertEquals(List.of(t2, t1), d.getTimeDimensionProjections()); + assertEquals(filterExpression, d.getWhereFilter()); + } + + @Test + public void testMultipleMergeSuccess() { + QueryPlan a = mock(QueryPlan.class); + QueryPlan b = mock(QueryPlan.class); + QueryPlan c = mock(QueryPlan.class); + + QueryPlanMerger merger = new QueryPlanMerger() { + @Override + public boolean canMerge(QueryPlan a, QueryPlan b) { + return true; + } + + @Override + public QueryPlan merge(QueryPlan a, QueryPlan b) { + return mock(QueryPlan.class); + } + }; + + List results = merger.merge(List.of(a, b, c)); + + assertEquals(1, results.size()); + } + + @Test + public void testMultipleMergeFailure() { + QueryPlan a = mock(QueryPlan.class); + QueryPlan b = mock(QueryPlan.class); + QueryPlan c = mock(QueryPlan.class); + + QueryPlanMerger merger = new QueryPlanMerger() { + @Override + public boolean canMerge(QueryPlan a, QueryPlan b) { + return false; + } + + @Override + public QueryPlan merge(QueryPlan a, QueryPlan b) { + return mock(QueryPlan.class); + } + }; + + List results = merger.merge(List.of(a, b, c)); + + assertEquals(3, results.size()); + assertEquals(List.of(a, b, c), results); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/DruidExplainQueryTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/DruidExplainQueryTest.java index c122f0bc3b..b5f7592cf4 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/DruidExplainQueryTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/DruidExplainQueryTest.java @@ -333,11 +333,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(\"example_PlayerStats\".\"highScore\") AS \"highScore\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" " + "GROUP BY \"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" " + "GROUP BY \"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\" ) AS \"pagination_subquery\"\n"; @@ -348,11 +348,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(\"example_PlayerStats\".\"highScore\") AS \"highScore\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" GROUP BY " + "\"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" GROUP BY " + "\"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\" " diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/EntityHydratorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/EntityHydratorTest.java index d28fa0f16a..e434607369 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/EntityHydratorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/EntityHydratorTest.java @@ -8,6 +8,10 @@ import static com.yahoo.elide.datastores.aggregation.query.ColumnProjection.createSafeAlias; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.yahoo.elide.core.request.Argument; @@ -24,7 +28,9 @@ import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; +import java.util.NoSuchElementException; public class EntityHydratorTest extends SQLUnitTest { @@ -33,6 +39,29 @@ public static void init() { SQLUnitTest.init(); } + @Test + void testEmptyResponse() throws Exception { + ResultSet resultSet = mock(ResultSet.class); + when(resultSet.next()).thenReturn(false); + + Map monthArguments = new HashMap<>(); + monthArguments.put("grain", Argument.builder().name("grain").value(TimeGrain.MONTH).build()); + + Map dayArguments = new HashMap<>(); + dayArguments.put("grain", Argument.builder().name("grain").value(TimeGrain.DAY).build()); + + Query query = Query.builder() + .source(playerStatsTable) .metricProjection(playerStatsTable.getMetricProjection("highScore")) + .timeDimensionProjection(playerStatsTable.getTimeDimensionProjection("recordedDate", "byMonth", monthArguments)) + .timeDimensionProjection(playerStatsTable.getTimeDimensionProjection("recordedDate", "byDay", dayArguments)) + .build(); + + EntityHydrator hydrator = new EntityHydrator(resultSet, query, dictionary); + Iterator iterator = hydrator.iterator(); + assertFalse(iterator.hasNext()); + assertThrows(NoSuchElementException.class, () -> hydrator.iterator().next()); + } + @Test void testTimeDimensionHydration() throws Exception { ResultSet resultSet = mock(ResultSet.class); @@ -57,9 +86,39 @@ void testTimeDimensionHydration() throws Exception { .build(); EntityHydrator hydrator = new EntityHydrator(resultSet, query, dictionary); - PlayerStats stats = (PlayerStats) hydrator.hydrate().iterator().next(); + + Iterator iterator = hydrator.iterator(); + assertTrue(iterator.hasNext()); + PlayerStats stats = (PlayerStats) iterator.next(); assertEquals(Month.class, stats.fetch("byMonth", null).getClass()); assertEquals(Day.class, stats.fetch("byDay", null).getClass()); + + assertFalse(iterator.hasNext()); + assertThrows(NoSuchElementException.class, () -> hydrator.iterator().next()); + } + + @Test + void testNullEnumHydration() throws Exception { + ResultSet resultSet = mock(ResultSet.class); + ResultSetMetaData resultSetMetaData = mock(ResultSetMetaData.class); + when(resultSet.next()).thenReturn(true, false); + when(resultSet.getObject("overallRating")).thenReturn(null); + when(resultSetMetaData.getColumnCount()).thenReturn(1); + + Query query = Query.builder() + .source(playerStatsTable) + .dimensionProjection(playerStatsTable.getDimensionProjection("overallRating")) + .build(); + + EntityHydrator hydrator = new EntityHydrator(resultSet, query, dictionary); + + Iterator iterator = hydrator.iterator(); + assertTrue(iterator.hasNext()); + PlayerStats stats = (PlayerStats) iterator.next(); + + assertNull(stats.getOverallRating()); + assertFalse(iterator.hasNext()); + assertThrows(NoSuchElementException.class, () -> hydrator.iterator().next()); } } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/H2ExplainQueryTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/H2ExplainQueryTest.java index 4712ece4c0..adb5a11a59 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/H2ExplainQueryTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/H2ExplainQueryTest.java @@ -341,11 +341,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(`example_PlayerStats`.`highScore`) AS `highScore`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` " + "GROUP BY `example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` " + "GROUP BY `example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate` ) AS `pagination_subquery`\n"; @@ -356,11 +356,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(`example_PlayerStats`.`highScore`) AS `highScore`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` GROUP BY " + "`example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` GROUP BY " + "`example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate` " diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/HiveExplainQueryTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/HiveExplainQueryTest.java index 45c5b9f5cd..242e84efbe 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/HiveExplainQueryTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/HiveExplainQueryTest.java @@ -330,11 +330,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(`example_PlayerStats`.`highScore`) AS `highScore`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` " + "GROUP BY `example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` " + "GROUP BY `example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate` ) AS `pagination_subquery`\n"; @@ -345,11 +345,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(`example_PlayerStats`.`highScore`) AS `highScore`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` GROUP BY " + "`example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` GROUP BY " + "`example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate` " diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/MySQLExplainQueryTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/MySQLExplainQueryTest.java index 353eae4e16..cb5d475e3d 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/MySQLExplainQueryTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/MySQLExplainQueryTest.java @@ -329,11 +329,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(`example_PlayerStats`.`highScore`) AS `highScore`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` " + "GROUP BY `example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` " + "GROUP BY `example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate` ) AS `pagination_subquery`\n"; @@ -344,11 +344,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(`example_PlayerStats`.`highScore`) AS `highScore`," + "`example_PlayerStats`.`overallRating` AS `overallRating`," + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `recordedDate_XXX`," - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `recordedDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `recordedDate` " + "FROM `playerStats` AS `example_PlayerStats` GROUP BY " + "`example_PlayerStats`.`overallRating`, " + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_PlayerStats`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_PlayerStats_XXX` GROUP BY " + "`example_PlayerStats_XXX`.`overallRating`, " + "`example_PlayerStats_XXX`.`recordedDate` " diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/PostgresExplainQueryTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/PostgresExplainQueryTest.java index c71a2e474c..cc21bdc31e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/PostgresExplainQueryTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/PostgresExplainQueryTest.java @@ -301,11 +301,11 @@ public void testNestedMetricQuery() { + "MIN(\"example_PlayerStats\".\"lowScore\") AS \"inner_agg_XXX\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" GROUP BY " + "\"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" GROUP BY " + "\"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\"\n"; @@ -329,11 +329,11 @@ public void testNestedMetricWithHavingQuery() { + "FROM (SELECT MAX(\"example_PlayerStats\".\"highScore\") AS \"highScore\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" GROUP BY " + "\"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" GROUP BY " + "\"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\" " @@ -358,13 +358,13 @@ public void testNestedMetricWithWhereQuery() { + "FROM (SELECT MAX(\"example_PlayerStats\".\"highScore\") AS \"highScore\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" " + "LEFT OUTER JOIN \"countries\" AS \"example_PlayerStats_country_XXX\" ON \"example_PlayerStats\".\"country_id\" = \"example_PlayerStats_country_XXX\".\"id\" " + "WHERE \"example_PlayerStats_country_XXX\".\"iso_code\" IN (:XXX) " + "GROUP BY \"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" GROUP BY " + "\"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\"\n"; @@ -387,11 +387,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(\"example_PlayerStats\".\"highScore\") AS \"highScore\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" " + "GROUP BY \"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" " + "GROUP BY \"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\" ) AS \"pagination_subquery\"\n"; @@ -402,11 +402,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(\"example_PlayerStats\".\"highScore\") AS \"highScore\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" GROUP BY " + "\"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" GROUP BY " + "\"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\" " @@ -432,11 +432,11 @@ public void testNestedMetricWithSortingQuery() { + "FROM (SELECT MAX(\"example_PlayerStats\".\"highScore\") AS \"highScore\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" GROUP BY " + "\"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" GROUP BY " + "\"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\" " diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/PrestoDBExplainQueryTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/PrestoDBExplainQueryTest.java index 20eedae29d..7b98af1ae1 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/PrestoDBExplainQueryTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/PrestoDBExplainQueryTest.java @@ -311,11 +311,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(\"example_PlayerStats\".\"highScore\") AS \"highScore\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" " + "GROUP BY \"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" " + "GROUP BY \"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\" ) AS \"pagination_subquery\"\n"; @@ -326,11 +326,11 @@ public void testNestedMetricWithPaginationQuery() { + "FROM (SELECT MAX(\"example_PlayerStats\".\"highScore\") AS \"highScore\"," + "\"example_PlayerStats\".\"overallRating\" AS \"overallRating\"," + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd') AS \"recordedDate_XXX\"," - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') AS \"recordedDate\" " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') AS \"recordedDate\" " + "FROM \"playerStats\" AS \"example_PlayerStats\" GROUP BY " + "\"example_PlayerStats\".\"overallRating\", " + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-dd'), 'yyyy-MM-dd'), " - + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(\"example_PlayerStats\".\"recordedDate\", 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS \"example_PlayerStats_XXX\" GROUP BY " + "\"example_PlayerStats_XXX\".\"overallRating\", " + "\"example_PlayerStats_XXX\".\"recordedDate\" " diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/QueryEngineTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/QueryEngineTest.java index 54414ec04c..a4caa2f76f 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/QueryEngineTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/QueryEngineTest.java @@ -67,7 +67,7 @@ public void testFullTableLoad() throws Exception { PlayerStats stats0 = new PlayerStats(); stats0.setId("0"); stats0.setLowScore(241); - stats0.setHighScore(2412); + stats0.setHighScore(3147483647L); stats0.setRecordedDate(new Day(Date.valueOf("2019-07-11"))); PlayerStats stats1 = new PlayerStats(); @@ -102,7 +102,7 @@ public void testFromSubQuery() throws Exception { PlayerStatsView stats2 = new PlayerStatsView(); stats2.setId("0"); - stats2.setHighScore(2412); + stats2.setHighScore(3147483647L); assertEquals(ImmutableList.of(stats2), results); } @@ -131,7 +131,7 @@ public void testAllArgumentQuery() throws Exception { PlayerStatsView stats2 = new PlayerStatsView(); stats2.setId("0"); - stats2.setHighScore(2412); + stats2.setHighScore(3147483647L); stats2.setCountryName("United States"); assertEquals(ImmutableList.of(stats2), results); @@ -182,7 +182,7 @@ public void testNotProjectedFilter() throws Exception { PlayerStatsView stats2 = new PlayerStatsView(); stats2.setId("0"); - stats2.setHighScore(2412); + stats2.setHighScore(3147483647L); assertEquals(ImmutableList.of(stats2), results); } @@ -284,6 +284,35 @@ public void testPagination() throws Exception { assertEquals(3, result.getPageTotals(), "Page totals does not match"); } + /** + * Nested Queries with filter - Pagination + * @throws Exception + */ + @Test + public void testPaginationWithFilter() throws Exception { + Query query = Query.builder() + .source(playerStatsTable) + .metricProjection(playerStatsTable.getMetricProjection("dailyAverageScorePerPeriod")) + .dimensionProjection(playerStatsTable.getDimensionProjection("overallRating")) + .whereFilter(filterParser.parseFilterExpression("overallRating==Great", playerStatsType, false)) + .timeDimensionProjection(playerStatsTable.getTimeDimensionProjection("recordedDate")) + .pagination(new ImmutablePagination(0, 1, false, true)) + .build(); + + QueryResult result = engine.executeQuery(query, transaction); + List data = toList(result.getData()); + + //Jon Doe,1234,72,Good,840,2019-07-12 00:00:00 + PlayerStats stats1 = new PlayerStats(); + stats1.setId("0"); + stats1.setDailyAverageScorePerPeriod(3.147483647E9); + stats1.setOverallRating("Great"); + stats1.setRecordedDate(new Day(Date.valueOf("2019-07-11"))); + + assertEquals(ImmutableList.of(stats1), data, "Returned record does not match"); + assertEquals(1, result.getPageTotals(), "Page totals does not match"); + } + /** * Test having clause integrates with group by clause. * @@ -327,18 +356,18 @@ public void testHavingClauseJoin() throws Exception { List results = toList(engine.executeQuery(query, transaction).getData()); PlayerStats stats0 = new PlayerStats(); - stats0.setId("0"); + stats0.setId("1"); stats0.setOverallRating("Great"); stats0.setCountryIsoCode("USA"); - stats0.setHighScore(2412); + stats0.setHighScore(3147483647L); PlayerStats stats1 = new PlayerStats(); - stats1.setId("1"); + stats1.setId("0"); stats1.setOverallRating("Good"); stats1.setCountryIsoCode("USA"); stats1.setHighScore(1234); - assertEquals(ImmutableList.of(stats0, stats1), results); + assertEquals(ImmutableList.of(stats1, stats0), results); } /** @@ -399,16 +428,16 @@ public void testJoinToGroupBy() throws Exception { List results = toList(engine.executeQuery(query, transaction).getData()); PlayerStats stats1 = new PlayerStats(); - stats1.setId("0"); - stats1.setHighScore(2412); + stats1.setId("1"); + stats1.setHighScore(3147483647L); stats1.setCountryIsoCode("USA"); PlayerStats stats2 = new PlayerStats(); - stats2.setId("1"); + stats2.setId("0"); stats2.setHighScore(1000); stats2.setCountryIsoCode("HKG"); - assertEquals(ImmutableList.of(stats1, stats2), results); + assertEquals(ImmutableList.of(stats2, stats1), results); } /** @@ -435,7 +464,7 @@ public void testJoinToFilter() throws Exception { PlayerStats stats2 = new PlayerStats(); stats2.setId("1"); stats2.setOverallRating("Great"); - stats2.setHighScore(2412); + stats2.setHighScore(3147483647L); assertEquals(ImmutableList.of(stats1, stats2), results); } @@ -477,7 +506,7 @@ public void testJoinToSort() throws Exception { stats3.setId("2"); stats3.setOverallRating("Great"); stats3.setCountryIsoCode("USA"); - stats3.setHighScore(2412); + stats3.setHighScore(3147483647L); assertEquals(ImmutableList.of(stats1, stats2, stats3), results); } @@ -500,7 +529,7 @@ public void testTotalScoreByMonth() throws Exception { PlayerStats stats0 = new PlayerStats(); stats0.setId("0"); - stats0.setHighScore(2412); + stats0.setHighScore(3147483647L); stats0.setRecordedDate(new Day(Date.valueOf("2019-07-11"))); PlayerStats stats1 = new PlayerStats(); @@ -540,7 +569,7 @@ public void testFilterByTemporalDimension() throws Exception { PlayerStats stats0 = new PlayerStats(); stats0.setId("0"); - stats0.setHighScore(2412); + stats0.setHighScore(3147483647L); stats0.setRecordedDate(new Day(Date.valueOf("2019-07-11"))); assertEquals(ImmutableList.of(stats0), results); @@ -593,16 +622,16 @@ public void testNullJoinToStringValue() throws Exception { List results = toList(engine.executeQuery(query, transaction).getData()); PlayerStats stats1 = new PlayerStats(); - stats1.setId("0"); - stats1.setHighScore(2412); + stats1.setId("1"); + stats1.setHighScore(3147483647L); stats1.setCountryNickName("Uncle Sam"); PlayerStats stats2 = new PlayerStats(); - stats2.setId("1"); + stats2.setId("0"); stats2.setHighScore(1000); stats2.setCountryNickName(null); - assertEquals(ImmutableList.of(stats1, stats2), results); + assertEquals(ImmutableList.of(stats2, stats1), results); } @Test @@ -616,16 +645,16 @@ public void testNullJoinToIntValue() throws Exception { List results = toList(engine.executeQuery(query, transaction).getData()); PlayerStats stats1 = new PlayerStats(); - stats1.setId("0"); - stats1.setHighScore(2412); + stats1.setId("1"); + stats1.setHighScore(3147483647L); stats1.setCountryUnSeats(1); PlayerStats stats2 = new PlayerStats(); - stats2.setId("1"); + stats2.setId("0"); stats2.setHighScore(1000); stats2.setCountryUnSeats(0); - assertEquals(ImmutableList.of(stats1, stats2), results); + assertEquals(ImmutableList.of(stats2, stats1), results); } @Test @@ -657,7 +686,7 @@ public void testMultipleTimeGrains() throws Exception { assertEquals(1234, results.get(1).getHighScore()); assertEquals(new Day(Date.valueOf("2019-07-12")), results.get(1).fetch("byDay", null)); assertEquals(new Month(Date.valueOf("2019-07-01")), results.get(1).fetch("byMonth", null)); - assertEquals(2412, results.get(2).getHighScore()); + assertEquals(3147483647L, results.get(2).getHighScore()); assertEquals(new Day(Date.valueOf("2019-07-11")), results.get(2).fetch("byDay", null)); assertEquals(new Month(Date.valueOf("2019-07-01")), results.get(2).fetch("byMonth", null)); } @@ -692,7 +721,7 @@ public void testMultipleTimeGrainsFilteredByDayAlias() throws Exception { List results = toList(engine.executeQuery(query, transaction).getData()); assertEquals(1, results.size()); - assertEquals(2412, results.get(0).getHighScore()); + assertEquals(3147483647L, results.get(0).getHighScore()); assertEquals(new Day(Date.valueOf("2019-07-11")), results.get(0).fetch("byDay", null)); assertEquals(new Month(Date.valueOf("2019-07-01")), results.get(0).fetch("byMonth", null)); } @@ -735,7 +764,7 @@ public void testMultipleTimeGrainsFilteredByMonthAlias() throws Exception { assertEquals(new Day(Date.valueOf("2019-07-12")), results.get(1).fetch("byDay", null)); assertEquals(new Month(Date.valueOf("2019-07-01")), results.get(1).fetch("byMonth", null)); - assertEquals(2412, results.get(2).getHighScore()); + assertEquals(3147483647L, results.get(2).getHighScore()); assertEquals(new Day(Date.valueOf("2019-07-11")), results.get(2).fetch("byDay", null)); assertEquals(new Month(Date.valueOf("2019-07-01")), results.get(2).fetch("byMonth", null)); } @@ -770,7 +799,7 @@ public void testMultipleTimeGrainsSortedByDayAlias() throws Exception { List results = toList(engine.executeQuery(query, transaction).getData()); assertEquals(3, results.size()); - assertEquals(2412, results.get(0).getHighScore()); + assertEquals(3147483647L, results.get(0).getHighScore()); assertEquals(new Day(Date.valueOf("2019-07-11")), results.get(0).fetch("byDay", null)); assertEquals(new Month(Date.valueOf("2019-07-01")), results.get(0).fetch("byMonth", null)); @@ -800,7 +829,7 @@ public void testMetricFormulaWithQueryPlan() throws Exception { PlayerStats stats0 = new PlayerStats(); stats0.setId("0"); - stats0.setDailyAverageScorePerPeriod(1549); + stats0.setDailyAverageScorePerPeriod(1.0491619603333334E9); stats0.setRecordedDate(new Month(Date.valueOf("2019-07-01"))); assertEquals(ImmutableList.of(stats0), results); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SubselectTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SubselectTest.java index 190793ac8e..cd9ecd9de0 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SubselectTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/SubselectTest.java @@ -54,16 +54,16 @@ public void testJoinToGroupBy() throws Exception { List results = toList(engine.executeQuery(query, transaction).getData()); PlayerStats stats1 = new PlayerStats(); - stats1.setId("0"); - stats1.setHighScore(2412); + stats1.setId("1"); + stats1.setHighScore(3147483647L); stats1.setSubCountryIsoCode("USA"); PlayerStats stats2 = new PlayerStats(); - stats2.setId("1"); + stats2.setId("0"); stats2.setHighScore(1000); stats2.setSubCountryIsoCode("HKG"); - assertEquals(ImmutableList.of(stats1, stats2), results); + assertEquals(ImmutableList.of(stats2, stats1), results); } /** @@ -90,7 +90,7 @@ public void testJoinToFilter() throws Exception { PlayerStats stats2 = new PlayerStats(); stats2.setId("1"); stats2.setOverallRating("Great"); - stats2.setHighScore(2412); + stats2.setHighScore(3147483647L); assertEquals(2, results.size()); assertEquals(stats1, results.get(0)); @@ -134,7 +134,7 @@ public void testJoinToSort() throws Exception { stats3.setId("2"); stats3.setOverallRating("Great"); stats3.setSubCountryIsoCode("USA"); - stats3.setHighScore(2412); + stats3.setHighScore(3147483647L); assertEquals(3, results.size()); assertEquals(stats1, results.get(0)); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteInnerAggregationExtractorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteInnerAggregationExtractorTest.java index 85fa149314..f27c579505 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteInnerAggregationExtractorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteInnerAggregationExtractorTest.java @@ -38,6 +38,26 @@ public void testExpressionParsing() throws Exception { assertEquals("SUM(`blah`)", aggregations.get(1).get(0)); } + @Test + public void testCaseStmtParsing() throws Exception { + String sql = " CASE\n" + + " WHEN SUM(blah) = 0 THEN 1\n" + + " ELSE SUM('number_of_lectures') / SUM(blah)\n" + + " END"; + SqlParser sqlParser = SqlParser.create(sql, CalciteUtils.constructParserConfig(dialect)); + SqlNode node = sqlParser.parseExpression(); + CalciteInnerAggregationExtractor extractor = new CalciteInnerAggregationExtractor(dialect); + List> aggregations = node.accept(extractor); + + assertEquals(3, aggregations.size()); + assertEquals(1, aggregations.get(0).size()); + assertEquals(1, aggregations.get(1).size()); + assertEquals(1, aggregations.get(2).size()); + assertEquals("SUM(`blah`)", aggregations.get(0).get(0)); + assertEquals("SUM('number_of_lectures')", aggregations.get(1).get(0)); + assertEquals("SUM(`blah`)", aggregations.get(2).get(0)); + } + @Test public void testInvalidAggregationFunction() throws Exception { String sql = "CUSTOM_SUM(blah)"; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteOuterAggregationExtractorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteOuterAggregationExtractorTest.java index 2378c53a31..683da1ce70 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteOuterAggregationExtractorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/calcite/CalciteOuterAggregationExtractorTest.java @@ -38,6 +38,24 @@ public void testExpressionParsing() throws Exception { assertEquals(expected, actual); } + @Test + public void testCaseStmtParsing() throws Exception { + String sql = " CASE\n" + + " WHEN SUM(blah) = 0 THEN 1\n" + + " ELSE SUM('number_of_lectures') / SUM(blah)\n" + + " END"; + + SqlParser sqlParser = SqlParser.create(sql, CalciteUtils.constructParserConfig(dialect)); + SqlNode node = sqlParser.parseExpression(); + + List> substitutions = Arrays.asList(Arrays.asList("SUB1"), Arrays.asList("SUB2"), Arrays.asList("SUB1")); + CalciteOuterAggregationExtractor extractor = new CalciteOuterAggregationExtractor(dialect, substitutions); + String actual = node.accept(extractor).toSqlString(dialect.getCalciteDialect()).getSql(); + String expected = "CASE WHEN SUM(`SUB1`) = 0 THEN 1 ELSE SUM(`SUB2`) / SUM(`SUB1`) END"; + + assertEquals(expected, actual); + } + @Test public void testCustomAggregationFunction() throws Exception { String sql = "CUSTOM_SUM(blah)"; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/ExpressionParserTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/ExpressionParserTest.java index fd3b1948e0..ad0f16bff0 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/ExpressionParserTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/ExpressionParserTest.java @@ -56,7 +56,7 @@ public ExpressionParserTest() { DataSource mockDataSource = mock(DataSource.class); //The query engine populates the metadata store with actual tables. - new SQLQueryEngine(metaDataStore, new ConnectionDetails(mockDataSource, + new SQLQueryEngine(metaDataStore, (unused) -> new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); parser = new ExpressionParser(metaDataStore); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/HasColumnArgsVisitorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/HasColumnArgsVisitorTest.java index 8b57e01a78..17c681505c 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/HasColumnArgsVisitorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/HasColumnArgsVisitorTest.java @@ -109,14 +109,12 @@ public HasColumnArgsVisitorTest() { EntityDictionary dictionary = EntityDictionary.builder().build(); - models.stream().forEach(dictionary::bindEntity); - metaDataStore = new MetaDataStore(dictionary.getScanner(), models, true); metaDataStore.populateEntityDictionary(dictionary); DataSource mockDataSource = mock(DataSource.class); //The query engine populates the metadata store with actual tables. - new SQLQueryEngine(metaDataStore, new ConnectionDetails(mockDataSource, + new SQLQueryEngine(metaDataStore, (unused) -> new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/JoinExpressionExtractorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/JoinExpressionExtractorTest.java index 6107a53ca4..7f8a98895d 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/JoinExpressionExtractorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/JoinExpressionExtractorTest.java @@ -67,7 +67,7 @@ public JoinExpressionExtractorTest() { DataSource mockDataSource = mock(DataSource.class); // The query engine populates the metadata store with actual tables. - engine = new SQLQueryEngine(metaDataStore, new ConnectionDetails(mockDataSource, new H2Dialect())); + engine = new SQLQueryEngine(metaDataStore, (unused) -> new ConnectionDetails(mockDataSource, new H2Dialect())); table = metaDataStore.getTable(ClassType.of(MainTable.class)); queryArgs = new HashMap<>(); queryArgs.put("tableArg", Argument.builder().name("tableArg").value("tableArgValue").build()); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/LogicalReferenceExtractorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/LogicalReferenceExtractorTest.java index 3c8ba5c4de..0417a70f16 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/LogicalReferenceExtractorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/LogicalReferenceExtractorTest.java @@ -53,7 +53,7 @@ public LogicalReferenceExtractorTest() { DataSource mockDataSource = mock(DataSource.class); //The query engine populates the metadata store with actual tables. - new SQLQueryEngine(metaDataStore, new ConnectionDetails(mockDataSource, + new SQLQueryEngine(metaDataStore, (unused) -> new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); parser = new ExpressionParser(metaDataStore); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/PhysicalReferenceExtractorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/PhysicalReferenceExtractorTest.java index dfdd3e16c8..84c300c521 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/PhysicalReferenceExtractorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/expression/PhysicalReferenceExtractorTest.java @@ -53,7 +53,7 @@ public PhysicalReferenceExtractorTest() { DataSource mockDataSource = mock(DataSource.class); //The query engine populates the metadata store with actual tables. - new SQLQueryEngine(metaDataStore, new ConnectionDetails(mockDataSource, + new SQLQueryEngine(metaDataStore, (unused) -> new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); parser = new ExpressionParser(metaDataStore); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/AggregateBeforeJoinOptimizerTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/AggregateBeforeJoinOptimizerTest.java index e2a2b0ecb9..ee4e49b18e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/AggregateBeforeJoinOptimizerTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/AggregateBeforeJoinOptimizerTest.java @@ -673,10 +673,10 @@ public void testHavingOnTimeDimensionInProjectionNotRequiringJoinWithArguments() + "`example_GameRevenue_XXX`.`saleDate` AS `saleDate` " + "FROM (SELECT MAX(`example_GameRevenue`.`revenue`) AS `INNER_AGG_XXX`," + "`example_GameRevenue`.`country_id` AS `country_id`," - + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM'), 'yyyy-MM') AS `saleDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `saleDate` " + "FROM `gameRevenue` AS `example_GameRevenue` " + "GROUP BY `example_GameRevenue`.`country_id`, " - + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_GameRevenue_XXX` " + "LEFT OUTER JOIN `countries` AS `example_GameRevenue_XXX_country_XXX` " + "ON `example_GameRevenue_XXX`.`country_id` = `example_GameRevenue_XXX_country_XXX`.`id` " @@ -761,7 +761,7 @@ public void testHavingOnTimeDimensionInProjectionRequiringJoinWithArguments() { .build(); compareQueryLists("SELECT MAX(`example_GameRevenue_XXX`.`INNER_AGG_XXX`) AS `revenue`," - + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `sessionDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `sessionDate` " + "FROM (SELECT MAX(`example_GameRevenue`.`revenue`) AS `INNER_AGG_XXX`," + "`example_GameRevenue`.`player_stats_id` AS `player_stats_id` " + "FROM `gameRevenue` AS `example_GameRevenue` " @@ -769,9 +769,9 @@ public void testHavingOnTimeDimensionInProjectionRequiringJoinWithArguments() { + "AS `example_GameRevenue_XXX` " + "LEFT OUTER JOIN `playerStats` AS `example_GameRevenue_XXX_playerStats_XXX` " + "ON `example_GameRevenue_XXX`.`player_stats_id` = `example_GameRevenue_XXX_playerStats_XXX`.`id` " - + "GROUP BY PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') " + + "GROUP BY PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') " + "HAVING (MAX(`example_GameRevenue_XXX`.`INNER_AGG_XXX`) > :XXX " - + "OR PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') IN (:XXX))\n", + + "OR PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') IN (:XXX))\n", engine.explain(query)); testQueryExecution(query); @@ -855,11 +855,11 @@ public void testWhereOnTimeDimensionInProjectionNotRequiringJoinWithMatchingArgu + "`example_GameRevenue_XXX`.`saleDate` AS `saleDate` " + "FROM (SELECT MAX(`example_GameRevenue`.`revenue`) AS `INNER_AGG_XXX`," + "`example_GameRevenue`.`country_id` AS `country_id`," - + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM'), 'yyyy-MM') AS `saleDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `saleDate` " + "FROM `gameRevenue` AS `example_GameRevenue` " - + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM'), 'yyyy-MM') IN (:XXX) " + + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') IN (:XXX) " + "GROUP BY `example_GameRevenue`.`country_id`, " - + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM'), 'yyyy-MM') ) " + + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') ) " + "AS `example_GameRevenue_XXX` " + "LEFT OUTER JOIN `countries` AS `example_GameRevenue_XXX_country_XXX` " + "ON `example_GameRevenue_XXX`.`country_id` = `example_GameRevenue_XXX_country_XXX`.`id` " @@ -957,7 +957,7 @@ public void testWhereOnTimeDimensionInProjectionNotRequiringJoinWithMismatchingA + "`example_GameRevenue`.`country_id` AS `country_id`," + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') AS `saleDate` " + "FROM `gameRevenue` AS `example_GameRevenue` " - + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM'), 'yyyy-MM') IN (:XXX) " + + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') IN (:XXX) " + "GROUP BY `example_GameRevenue`.`country_id`, " + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd') ) " + "AS `example_GameRevenue_XXX` " @@ -1037,7 +1037,7 @@ public void testWhereOnTimeDimensionNotInProjectionNotRequiringJoinWithArguments + "FROM (SELECT MAX(`example_GameRevenue`.`revenue`) AS `INNER_AGG_XXX`," + "`example_GameRevenue`.`country_id` AS `country_id` " + "FROM `gameRevenue` AS `example_GameRevenue` " - + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM'), 'yyyy-MM') IN (:XXX) " + + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue`.`saleDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') IN (:XXX) " + "GROUP BY `example_GameRevenue`.`country_id` ) " + "AS `example_GameRevenue_XXX` " + "LEFT OUTER JOIN `countries` AS `example_GameRevenue_XXX_country_XXX` " @@ -1111,7 +1111,7 @@ public void testWhereOnTimeDimensionNotInProjectionRequiringJoinWithArguments() + "AS `example_GameRevenue_XXX` " + "LEFT OUTER JOIN `playerStats` AS `example_GameRevenue_XXX_playerStats_XXX` " + "ON `example_GameRevenue_XXX`.`player_stats_id` = `example_GameRevenue_XXX_playerStats_XXX`.`id` " - + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') IN (:XXX)\n"; + + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') IN (:XXX)\n"; compareQueryLists(expected, engine.explain(query)); @@ -1183,7 +1183,7 @@ public void testWhereOnTimeDimensionInProjectionRequiringJoinWithMismatchingArgu + "GROUP BY `example_GameRevenue`.`player_stats_id` ) AS `example_GameRevenue_XXX` " + "LEFT OUTER JOIN `playerStats` AS `example_GameRevenue_XXX_playerStats_XXX` " + "ON `example_GameRevenue_XXX`.`player_stats_id` = `example_GameRevenue_XXX_playerStats_XXX`.`id` " - + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') IN (:XXX) " + + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') IN (:XXX) " + "GROUP BY PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM-dd'), 'yyyy-MM-dd')\n"; compareQueryLists(expected, engine.explain(query)); @@ -1216,15 +1216,15 @@ public void testWhereOnTimeDimensionInProjectionRequiringJoinWithMatchingArgumen .build(); String expected = "SELECT MAX(`example_GameRevenue_XXX`.`INNER_AGG_XXX`) AS `revenue`," - + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') AS `sessionDate` " + + "PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') AS `sessionDate` " + "FROM (SELECT MAX(`example_GameRevenue`.`revenue`) AS `INNER_AGG_XXX`," + "`example_GameRevenue`.`player_stats_id` AS `player_stats_id` " + "FROM `gameRevenue` AS `example_GameRevenue` " + "GROUP BY `example_GameRevenue`.`player_stats_id` ) AS `example_GameRevenue_XXX` " + "LEFT OUTER JOIN `playerStats` AS `example_GameRevenue_XXX_playerStats_XXX` " + "ON `example_GameRevenue_XXX`.`player_stats_id` = `example_GameRevenue_XXX_playerStats_XXX`.`id` " - + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM') IN (:XXX) " - + "GROUP BY PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM'), 'yyyy-MM')\n"; + + "WHERE PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd') IN (:XXX) " + + "GROUP BY PARSEDATETIME(FORMATDATETIME(`example_GameRevenue_XXX_playerStats_XXX`.`recordedDate`, 'yyyy-MM-01'), 'yyyy-MM-dd')\n"; compareQueryLists(expected, engine.explain(query)); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLColumnProjectionTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLColumnProjectionTest.java index 3d221e5829..c3a605794e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLColumnProjectionTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SQLColumnProjectionTest.java @@ -78,7 +78,7 @@ public SQLColumnProjectionTest() { DataSource mockDataSource = mock(DataSource.class); //The query engine populates the metadata store with actual tables. - new SQLQueryEngine(metaDataStore, new ConnectionDetails(mockDataSource, + new SQLQueryEngine(metaDataStore, (unused) -> new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SubqueryFilterSplitterTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SubqueryFilterSplitterTest.java index e0aff04302..507bbd6eb2 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SubqueryFilterSplitterTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/queryengines/sql/query/SubqueryFilterSplitterTest.java @@ -55,10 +55,10 @@ public SubqueryFilterSplitterTest() { DataSource mockDataSource = mock(DataSource.class); //The query engine populates the metadata store with actual tables. - new SQLQueryEngine(metaDataStore, new ConnectionDetails(mockDataSource, + new SQLQueryEngine(metaDataStore, (unused) -> new ConnectionDetails(mockDataSource, SQLDialectFactory.getDefaultDialect())); - dialect = new RSQLFilterDialect(dictionary); + dialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/DaySerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/DaySerdeTest.java index dd4beb037c..52ab2a813d 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/DaySerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/DaySerdeTest.java @@ -12,8 +12,9 @@ import org.junit.jupiter.api.Test; import java.sql.Timestamp; -import java.text.ParseException; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -21,7 +22,7 @@ public class DaySerdeTest { private DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE; @Test - public void testDateSerialize() throws ParseException { + public void testDateSerialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Day expectedDate = new Day(localDate); @@ -31,7 +32,7 @@ public void testDateSerialize() throws ParseException { } @Test - public void testDateDeserialize() throws ParseException { + public void testDateDeserialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Day expectedDate = new Day(localDate); Serde serde = new Day.DaySerde(); @@ -40,7 +41,7 @@ public void testDateDeserialize() throws ParseException { } @Test - public void testDeserializeTimestamp() throws ParseException { + public void testDeserializeTimestamp() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Day expectedDate = new Day(localDate); @@ -51,7 +52,18 @@ public void testDeserializeTimestamp() throws ParseException { } @Test - public void testDeserializeDateInvalidFormat() throws ParseException { + public void testDeserializeOffsetDateTime() { + LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); + Day expectedDate = new Day(localDate); + OffsetDateTime dateTime = OffsetDateTime.of(2020, 01, 01, 00, 00, 00, 00, ZoneOffset.UTC); + + Serde serde = new Day.DaySerde(); + Object actualDate = serde.deserialize(dateTime); + assertEquals(expectedDate, actualDate); + } + + @Test + public void testDeserializeDateInvalidFormat() { String dateInString = "January-01-2020"; Serde serde = new Day.DaySerde(); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/HourSerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/HourSerdeTest.java index 37a3add422..ba6e50acff 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/HourSerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/HourSerdeTest.java @@ -12,8 +12,9 @@ import org.junit.jupiter.api.Test; import java.sql.Timestamp; -import java.text.ParseException; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -21,7 +22,7 @@ public class HourSerdeTest { private DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH"); @Test - public void testDateSerialize() throws ParseException { + public void testDateSerialize() { String expected = "2020-01-01T01"; Hour expectedDate = new Hour(LocalDateTime.from(formatter.parse(expected))); @@ -31,7 +32,7 @@ public void testDateSerialize() throws ParseException { } @Test - public void testDateDeserializeString() throws ParseException { + public void testDateDeserializeString() { String dateInString = "2020-01-01T01"; Hour expectedDate = new Hour(LocalDateTime.from(formatter.parse(dateInString))); @@ -41,7 +42,7 @@ public void testDateDeserializeString() throws ParseException { } @Test - public void testDeserializeTimestamp() throws ParseException { + public void testDeserializeTimestamp() { String dateInString = "2020-01-01T01"; Hour expectedDate = new Hour(LocalDateTime.from(formatter.parse(dateInString))); @@ -52,7 +53,18 @@ public void testDeserializeTimestamp() throws ParseException { } @Test - public void testDeserializeDateInvalidFormat() throws ParseException { + public void testDeserializeOffsetDateTime() { + LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 01, 00, 00); + Hour expectedDate = new Hour(localDate); + OffsetDateTime dateTime = OffsetDateTime.of(2020, 01, 01, 01, 00, 00, 00, ZoneOffset.UTC); + + Serde serde = new Hour.HourSerde(); + Object actualDate = serde.deserialize(dateTime); + assertEquals(expectedDate, actualDate); + } + + @Test + public void testDeserializeDateInvalidFormat() { String dateInString = "00 2020-01-01"; Serde serde = new Hour.HourSerde(); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/ISOWeekSerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/ISOWeekSerdeTest.java index ae8e173057..89557ae75f 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/ISOWeekSerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/ISOWeekSerdeTest.java @@ -12,8 +12,9 @@ import org.junit.jupiter.api.Test; import java.sql.Timestamp; -import java.text.ParseException; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -21,7 +22,7 @@ public class ISOWeekSerdeTest { private DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE; @Test - public void testDateSerialize() throws ParseException { + public void testDateSerialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 06, 00, 00, 00); ISOWeek expectedDate = new ISOWeek(localDate); @@ -31,7 +32,7 @@ public void testDateSerialize() throws ParseException { } @Test - public void testDateDeserializeString() throws ParseException { + public void testDateDeserializeString() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 06, 00, 00, 00); ISOWeek expectedDate = new ISOWeek(localDate); @@ -41,7 +42,7 @@ public void testDateDeserializeString() throws ParseException { } @Test - public void testDeserializeTimestampNotMonday() throws ParseException { + public void testDeserializeTimestampNotMonday() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); ISOWeek timestamp = new ISOWeek(localDate); Serde serde = new ISOWeek.ISOWeekSerde(); @@ -51,7 +52,29 @@ public void testDeserializeTimestampNotMonday() throws ParseException { } @Test - public void testDeserializeTimestamp() throws ParseException { + public void testDeserializeOffsetDateTime() { + LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 06, 00, 00, 00); + ISOWeek expectedDate = new ISOWeek(localDate); + + OffsetDateTime dateTime = OffsetDateTime.of(2020, 01, 06, 00, 00, 00, 00, ZoneOffset.UTC); + + Serde serde = new ISOWeek.ISOWeekSerde(); + Object actualDate = serde.deserialize(dateTime); + assertEquals(expectedDate, actualDate); + } + + @Test + public void testDeserializeOffsetDateTimeNotMonday() { + OffsetDateTime dateTime = OffsetDateTime.of(2020, 01, 01, 00, 00, 00, 00, ZoneOffset.UTC); + + Serde serde = new ISOWeek.ISOWeekSerde(); + assertThrows(IllegalArgumentException.class, () -> + serde.deserialize(dateTime) + ); + } + + @Test + public void testDeserializeTimestamp() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 06, 00, 00, 00); ISOWeek expectedDate = new ISOWeek(localDate); Timestamp timestamp = new Timestamp(expectedDate.getTime()); @@ -61,7 +84,7 @@ public void testDeserializeTimestamp() throws ParseException { } @Test - public void testDeserializeDateInvalidFormat() throws ParseException { + public void testDeserializeDateInvalidFormat() { String dateInString = "January-2020-01"; Serde serde = new ISOWeek.ISOWeekSerde(); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/MinuteSerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/MinuteSerdeTest.java index 3f536ff1e2..6742987abc 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/MinuteSerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/MinuteSerdeTest.java @@ -12,8 +12,9 @@ import org.junit.jupiter.api.Test; import java.sql.Timestamp; -import java.text.ParseException; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -21,7 +22,7 @@ public class MinuteSerdeTest { private DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"); @Test - public void testDateSerialize() throws ParseException { + public void testDateSerialize() { String expected = "2020-01-01T01:18"; Minute expectedDate = new Minute(LocalDateTime.from(formatter.parse(expected))); @@ -31,7 +32,7 @@ public void testDateSerialize() throws ParseException { } @Test - public void testDateDeserializeString() throws ParseException { + public void testDateDeserializeString() { String dateInString = "2020-01-01T01:18"; Minute expectedDate = new Minute(LocalDateTime.from(formatter.parse(dateInString))); @@ -42,7 +43,7 @@ public void testDateDeserializeString() throws ParseException { } @Test - public void testDeserializeTimestamp() throws ParseException { + public void testDeserializeTimestamp() { String dateInString = "2020-01-01T01:18"; Minute expectedDate = new Minute(LocalDateTime.from(formatter.parse(dateInString))); @@ -53,7 +54,18 @@ public void testDeserializeTimestamp() throws ParseException { } @Test - public void testDeserializeDateInvalidFormat() throws ParseException { + public void testDeserializeOffsetDateTime() { + String dateInString = "2020-01-01T01:18"; + Minute expectedDate = new Minute(LocalDateTime.from(formatter.parse(dateInString))); + + OffsetDateTime dateTime = OffsetDateTime.of(2020, 01, 01, 01, 18, 0, 0, ZoneOffset.UTC); + Serde serde = new Minute.MinuteSerde(); + Object actualDate = serde.deserialize(dateTime); + assertEquals(expectedDate, actualDate); + } + + @Test + public void testDeserializeDateInvalidFormat() { String dateInString = "00:18 2020-01-01"; Serde serde = new Minute.MinuteSerde(); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/MonthSerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/MonthSerdeTest.java index 715abb5fcd..addad39896 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/MonthSerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/MonthSerdeTest.java @@ -12,8 +12,9 @@ import org.junit.jupiter.api.Test; import java.sql.Timestamp; -import java.text.ParseException; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -21,7 +22,7 @@ public class MonthSerdeTest { private DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM"); @Test - public void testDateSerialize() throws ParseException { + public void testDateSerialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Month expectedDate = new Month(localDate); @@ -31,7 +32,7 @@ public void testDateSerialize() throws ParseException { } @Test - public void testDateDeserialize() throws ParseException { + public void testDateDeserialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Month expectedDate = new Month(localDate); Serde serde = new Month.MonthSerde(); @@ -40,7 +41,7 @@ public void testDateDeserialize() throws ParseException { } @Test - public void testDeserializeTimestamp() throws ParseException { + public void testDeserializeTimestamp() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Month expectedDate = new Month(localDate); Timestamp timestamp = new Timestamp(expectedDate.getTime()); @@ -50,7 +51,18 @@ public void testDeserializeTimestamp() throws ParseException { } @Test - public void testDeserializeDateInvalidFormat() throws ParseException { + public void testDeserializeOffsetDateTime() { + LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); + Month expectedDate = new Month(localDate); + + OffsetDateTime dateTime = OffsetDateTime.of(2020, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC); + Serde serde = new Month.MonthSerde(); + Object actualDate = serde.deserialize(dateTime); + assertEquals(expectedDate, actualDate); + } + + @Test + public void testDeserializeDateInvalidFormat() { String dateInString = "January-2020"; Serde serde = new Month.MonthSerde(); assertThrows(DateTimeParseException.class, () -> diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/QuarterSerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/QuarterSerdeTest.java index 99f784a821..b2c8528146 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/QuarterSerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/QuarterSerdeTest.java @@ -12,8 +12,9 @@ import org.junit.jupiter.api.Test; import java.sql.Timestamp; -import java.text.ParseException; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -21,7 +22,7 @@ public class QuarterSerdeTest { private DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM"); @Test - public void testDateSerialize() throws ParseException { + public void testDateSerialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Quarter expectedDate = new Quarter(localDate); Serde serde = new Quarter.QuarterSerde(); @@ -30,7 +31,7 @@ public void testDateSerialize() throws ParseException { } @Test - public void testDateDeserialize() throws ParseException { + public void testDateDeserialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Quarter expectedDate = new Quarter(localDate); Serde serde = new Quarter.QuarterSerde(); @@ -39,7 +40,7 @@ public void testDateDeserialize() throws ParseException { } @Test - public void testDeserializeTimestampNotQuarterMonth() throws ParseException { + public void testDeserializeTimestampNotQuarterMonth() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(02), 01, 00, 00, 00); Quarter quarter = new Quarter(localDate); Serde serde = new Quarter.QuarterSerde(); @@ -49,7 +50,7 @@ public void testDeserializeTimestampNotQuarterMonth() throws ParseException { } @Test - public void testDeserializeTimestamp() throws ParseException { + public void testDeserializeTimestamp() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Quarter expectedDate = new Quarter(localDate); Timestamp timestamp = new Timestamp(expectedDate.getTime()); @@ -59,7 +60,27 @@ public void testDeserializeTimestamp() throws ParseException { } @Test - public void testDeserializeDateInvalidFormat() throws ParseException { + public void testDeserializeOffsetDateTimeNotQuarterMonth() { + OffsetDateTime dateTime = OffsetDateTime.of(2020, 2, 1, 0, 0, 0, 0, ZoneOffset.UTC); + + Serde serde = new Quarter.QuarterSerde(); + assertThrows(IllegalArgumentException.class, () -> + serde.deserialize(dateTime) + ); + } + + @Test + public void testDeserializeOffsetDateTime() { + LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); + Quarter expectedDate = new Quarter(localDate); + OffsetDateTime dateTime = OffsetDateTime.of(2020, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC); + Serde serde = new Quarter.QuarterSerde(); + Object actualDate = serde.deserialize(dateTime); + assertEquals(expectedDate, actualDate); + } + + @Test + public void testDeserializeDateInvalidFormat() { String dateInString = "January-2020"; Serde serde = new Quarter.QuarterSerde(); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/SecondSerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/SecondSerdeTest.java index ab53104d2c..b57571d7d2 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/SecondSerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/SecondSerdeTest.java @@ -12,8 +12,9 @@ import org.junit.jupiter.api.Test; import java.sql.Timestamp; -import java.text.ParseException; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -21,7 +22,7 @@ public class SecondSerdeTest { private DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); @Test - public void testDateSerialize() throws ParseException { + public void testDateSerialize() { String expected = "2020-01-01T01:18:19"; Second expectedDate = new Second(LocalDateTime.from(formatter.parse(expected))); @@ -31,7 +32,7 @@ public void testDateSerialize() throws ParseException { } @Test - public void testDateDeserializeString() throws ParseException { + public void testDateDeserializeString() { String dateInString = "2020-01-01T01:18:19"; Second expectedDate = new Second(LocalDateTime.from(formatter.parse(dateInString))); @@ -42,7 +43,7 @@ public void testDateDeserializeString() throws ParseException { } @Test - public void testDeserializeTimestamp() throws ParseException { + public void testDeserializeTimestamp() { String dateInString = "2020-01-01T01:18:19"; Second expectedDate = new Second(LocalDateTime.from(formatter.parse(dateInString))); @@ -53,7 +54,18 @@ public void testDeserializeTimestamp() throws ParseException { } @Test - public void testDeserializeDateInvalidFormat() throws ParseException { + public void testDeserializeOffsetDateTime() { + String dateInString = "2020-01-01T01:18:19"; + Second expectedDate = new Second(LocalDateTime.from(formatter.parse(dateInString))); + + OffsetDateTime dateTime = OffsetDateTime.of(2020, 1, 1, 1, 18, 19, 0, ZoneOffset.UTC); + Serde serde = new Second.SecondSerde(); + Object actualDate = serde.deserialize(dateTime); + assertEquals(expectedDate, actualDate); + } + + @Test + public void testDeserializeDateInvalidFormat() { String dateInString = "00:18:19 2020-01-01"; Serde serde = new Second.SecondSerde(); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/TimeSerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/TimeSerdeTest.java index e957ebbdf7..af9424a1f1 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/TimeSerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/TimeSerdeTest.java @@ -11,7 +11,6 @@ import com.yahoo.elide.datastores.aggregation.timegrains.Time; import org.junit.jupiter.api.Test; -import java.text.ParseException; import java.time.LocalDateTime; public class TimeSerdeTest { @@ -24,7 +23,7 @@ public class TimeSerdeTest { private static final String SECOND = "2020-01-01T00:00:00"; @Test - public void testTimeDeserializeYear() throws ParseException { + public void testTimeDeserializeYear() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Time expectedDate = new Time(localDate, true, false, false, false, false, false, (unused) -> ""); Serde serde = new Time.TimeSerde(); @@ -34,7 +33,7 @@ public void testTimeDeserializeYear() throws ParseException { } @Test - public void testTimeDeserializeMonth() throws ParseException { + public void testTimeDeserializeMonth() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Time expectedDate = new Time(localDate, true, true, false, false, false, false, (unused) -> ""); Serde serde = new Time.TimeSerde(); @@ -44,7 +43,7 @@ public void testTimeDeserializeMonth() throws ParseException { } @Test - public void testTimeDeserializeDate() throws ParseException { + public void testTimeDeserializeDate() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Time expectedDate = new Time(localDate, true, true, true, false, false, false, (unused) -> ""); Serde serde = new Time.TimeSerde(); @@ -54,7 +53,7 @@ public void testTimeDeserializeDate() throws ParseException { } @Test - public void testTimeDeserializeHour() throws ParseException { + public void testTimeDeserializeHour() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Time expectedDate = new Time(localDate, true, true, true, true, true, true, (unused) -> ""); Serde serde = new Time.TimeSerde(); @@ -64,7 +63,7 @@ public void testTimeDeserializeHour() throws ParseException { } @Test - public void testTimeDeserializeMinute() throws ParseException { + public void testTimeDeserializeMinute() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Time expectedDate = new Time(localDate, true, true, true, true, true, true, (unused) -> ""); Serde serde = new Time.TimeSerde(); @@ -74,7 +73,7 @@ public void testTimeDeserializeMinute() throws ParseException { } @Test - public void testTimeDeserializeSecond() throws ParseException { + public void testTimeDeserializeSecond() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Time expectedDate = new Time(localDate, true, true, true, true, true, true, (unused) -> ""); Serde serde = new Time.TimeSerde(); @@ -84,7 +83,7 @@ public void testTimeDeserializeSecond() throws ParseException { } @Test - public void testInvalidDeserialization() throws ParseException { + public void testInvalidDeserialization() { Serde serde = new Time.TimeSerde(); assertThrows(IllegalArgumentException.class, () -> serde.deserialize("2020R1") diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/WeekSerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/WeekSerdeTest.java index 44af427f60..e99bfdf52e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/WeekSerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/WeekSerdeTest.java @@ -12,8 +12,9 @@ import org.junit.jupiter.api.Test; import java.sql.Timestamp; -import java.text.ParseException; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -21,7 +22,7 @@ public class WeekSerdeTest { private DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE; @Test - public void testDateSerialize() throws ParseException { + public void testDateSerialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 05, 00, 00, 00); Week expectedDate = new Week(localDate); @@ -31,7 +32,7 @@ public void testDateSerialize() throws ParseException { } @Test - public void testDateDeserializeString() throws ParseException { + public void testDateDeserializeString() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 05, 00, 00, 00); Week expectedDate = new Week(localDate); Serde serde = new Week.WeekSerde(); @@ -40,7 +41,7 @@ public void testDateDeserializeString() throws ParseException { } @Test - public void testDeserializeTimestampNotSunday() throws ParseException { + public void testDeserializeTimestampNotSunday() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 06, 00, 00, 00); Week expectedDate = new Week(localDate); @@ -51,7 +52,7 @@ public void testDeserializeTimestampNotSunday() throws ParseException { } @Test - public void testDeserializeTimestamp() throws ParseException { + public void testDeserializeTimestamp() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 05, 00, 00, 00); Week expectedDate = new Week(localDate); @@ -62,7 +63,29 @@ public void testDeserializeTimestamp() throws ParseException { } @Test - public void testDeserializeDateInvalidFormat() throws ParseException { + public void testDeserializeOffsetDateTimeNotSunday() { + OffsetDateTime dateTime = OffsetDateTime.of(2020, 1, 6, 0, 0, 0, 0, ZoneOffset.UTC); + + Serde serde = new Week.WeekSerde(); + assertThrows(IllegalArgumentException.class, () -> + serde.deserialize(dateTime) + ); + } + + @Test + public void testDeserializeOffsetDateTime() { + LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 05, 00, 00, 00); + + Week expectedDate = new Week(localDate); + + OffsetDateTime dateTime = OffsetDateTime.of(2020, 1, 5, 0, 0, 0, 0, ZoneOffset.UTC); + Serde serde = new Week.WeekSerde(); + Object actualDate = serde.deserialize(dateTime); + assertEquals(expectedDate, actualDate); + } + + @Test + public void testDeserializeDateInvalidFormat() { String dateInString = "January-2020-01"; Serde serde = new Week.WeekSerde(); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/YearSerdeTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/YearSerdeTest.java index 2852e3bb1e..3b82b7b46e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/YearSerdeTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/timegrains/serde/YearSerdeTest.java @@ -12,8 +12,9 @@ import org.junit.jupiter.api.Test; import java.sql.Timestamp; -import java.text.ParseException; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; @@ -21,7 +22,7 @@ public class YearSerdeTest { private DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy"); @Test - public void testDateSerialize() throws ParseException { + public void testDateSerialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Year expectedDate = new Year(localDate); @@ -31,7 +32,7 @@ public void testDateSerialize() throws ParseException { } @Test - public void testDateDeserialize() throws ParseException { + public void testDateDeserialize() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Year expectedDate = new Year(localDate); @@ -41,7 +42,7 @@ public void testDateDeserialize() throws ParseException { } @Test - public void testDeserializeTimestamp() throws ParseException { + public void testDeserializeTimestamp() { LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); Year expectedDate = new Year(localDate); @@ -52,7 +53,18 @@ public void testDeserializeTimestamp() throws ParseException { } @Test - public void testDeserializeDateInvalidFormat() throws ParseException { + public void testDeserializeOffsetDateTime() { + LocalDateTime localDate = LocalDateTime.of(2020, java.time.Month.of(01), 01, 00, 00, 00); + Year expectedDate = new Year(localDate); + + OffsetDateTime dateTime = OffsetDateTime.of(2020, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC); + Serde serde = new Year.YearSerde(); + Object actualDate = serde.deserialize(dateTime); + assertEquals(expectedDate, actualDate); + } + + @Test + public void testDeserializeDateInvalidFormat() { String dateInString = "January"; Serde serde = new Year.YearSerde(); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/validator/ColumnArgumentValidatorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/validator/ColumnArgumentValidatorTest.java index 864b016847..b665ebefe9 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/validator/ColumnArgumentValidatorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/validator/ColumnArgumentValidatorTest.java @@ -14,7 +14,9 @@ import com.yahoo.elide.datastores.aggregation.DefaultQueryValidator; import com.yahoo.elide.datastores.aggregation.QueryValidator; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; import com.yahoo.elide.datastores.aggregation.query.Optimizer; +import com.yahoo.elide.datastores.aggregation.query.QueryPlanMerger; import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; import com.yahoo.elide.datastores.aggregation.queryengines.sql.SQLQueryEngine; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; @@ -33,22 +35,23 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.function.Function; class ColumnArgumentValidatorTest { - private final ConnectionDetails connection; - private final Map connectionDetailsMap = new HashMap<>(); private final Set optimizers = new HashSet<>(); private final QueryValidator queryValidator = new DefaultQueryValidator(EntityDictionary.builder().build()); private Table.TableBuilder mainTableBuilder, joinTableBuilder; private final Argument mainArg1; private final Collection namespaceConfigs; + private final Function connectionLookup; public ColumnArgumentValidatorTest() { - this.connection = new ConnectionDetails(new HikariDataSource(), SQLDialectFactory.getDefaultDialect()); - this.connectionDetailsMap.put("mycon", this.connection); - this.connectionDetailsMap.put("SalesDBConnection", this.connection); + ConnectionDetails connection = new ConnectionDetails(new HikariDataSource(), SQLDialectFactory.getDefaultDialect()); + Map connectionDetailsMap = new HashMap<>(); + connectionDetailsMap.put("mycon", connection); + connectionDetailsMap.put("SalesDBConnection", connection); this.namespaceConfigs = Collections.singleton(NamespaceConfig.builder().name("namespace").build()); @@ -65,6 +68,8 @@ public ColumnArgumentValidatorTest() { .values(Collections.emptySet()) .defaultValue("") .build(); + + connectionLookup = (name) -> connectionDetailsMap.getOrDefault(name, connection); } @Test @@ -83,7 +88,8 @@ public void testUndefinedColumnArgsInColumnDefinition() { Set
tables = new HashSet<>(); tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify column arguments for column: dim1 in table: namespace_MainTable. Argument 'mainArg2' is not defined but found '{{$$column.args.mainArg2}}'.", e.getMessage()); @@ -112,7 +118,8 @@ public void testUndefinedColumnArgsInJoinExpression() { tables.add(mainTable); tables.add(joinTableBuilder.build()); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify column arguments for column: dim1 in table: namespace_MainTable. Argument 'mainArg2' is not defined but found '{{$$column.args.mainArg2}}'.", e.getMessage()); @@ -148,7 +155,8 @@ public void testColumnArgsTypeMismatchForDepenedentColumn() { tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify column arguments for column: dim1 in table: namespace_MainTable. Argument type mismatch. Dependent Column: 'dim2' in table: 'namespace_MainTable'" + " has same Argument: 'mainArg1' with type 'TEXT'.", @@ -170,7 +178,8 @@ public void testMissingRequiredColumnArgs() { Set
tables = new HashSet<>(); tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify column arguments for column: dim1 in table: namespace_MainTable. Argument 'mainArg1' is not defined but found '{{$$column.args.mainArg1}}'.", e.getMessage()); @@ -192,7 +201,8 @@ public void testRequiredColumnArgsInFilterTemplate() { Set
tables = new HashSet<>(); tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - assertDoesNotThrow(() -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + assertDoesNotThrow(() -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); } @Test @@ -225,7 +235,8 @@ public void testMissingRequiredColumnArgsForDepenedentColumnCase1() { tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify column arguments for column: dim1 in table: namespace_MainTable. Argument 'dependentArg1' with type 'INTEGER' is not defined but is" + " required for Dependent Column: 'dim2' in table: 'namespace_MainTable'.", @@ -260,7 +271,7 @@ public void testMissingRequiredColumnArgsForDepenedentColumnCase1() { tables.add(mainTable); MetaDataStore metaDataStore1 = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - assertDoesNotThrow(() -> new SQLQueryEngine(metaDataStore1, connection, connectionDetailsMap, optimizers, queryValidator)); + assertDoesNotThrow(() -> new SQLQueryEngine(metaDataStore1, connectionLookup, optimizers, merger, queryValidator)); } @Test @@ -303,7 +314,8 @@ public void testMissingRequiredColumnArgsForDepenedentColumnCase2() { tables.add(joinTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify column arguments for column: dim1 in table: namespace_MainTable. Argument 'joinArg1' with type 'INTEGER' is not defined but is" + " required for Dependent Column: 'joinCol' in table: 'namespace_JoinTable'.", @@ -332,7 +344,7 @@ public void testMissingRequiredColumnArgsForDepenedentColumnCase2() { tables.add(joinTable); MetaDataStore metaDataStore1 = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - assertDoesNotThrow(() -> new SQLQueryEngine(metaDataStore1, connection, connectionDetailsMap, optimizers, queryValidator)); + assertDoesNotThrow(() -> new SQLQueryEngine(metaDataStore1, connectionLookup, optimizers, merger, queryValidator)); } @Test @@ -366,7 +378,8 @@ public void testInvalidPinnedArgForDepenedentColumn() { tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify column arguments for column: dim1 in table: namespace_MainTable. Type mismatch of Fixed value provided for Dependent Column: 'dim2'" + " in table: 'namespace_MainTable'. Pinned Value: 'foo' for Argument 'dependentArg1' with Type 'INTEGER' is invalid.", diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/validator/TableArgumentValidatorTest.java b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/validator/TableArgumentValidatorTest.java index f5b4e3dda2..f2cd9fd885 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/validator/TableArgumentValidatorTest.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/com/yahoo/elide/datastores/aggregation/validator/TableArgumentValidatorTest.java @@ -14,7 +14,9 @@ import com.yahoo.elide.datastores.aggregation.DefaultQueryValidator; import com.yahoo.elide.datastores.aggregation.QueryValidator; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; import com.yahoo.elide.datastores.aggregation.query.Optimizer; +import com.yahoo.elide.datastores.aggregation.query.QueryPlanMerger; import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; import com.yahoo.elide.datastores.aggregation.queryengines.sql.SQLQueryEngine; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; @@ -34,21 +36,22 @@ import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.function.Function; public class TableArgumentValidatorTest { - private final ConnectionDetails connection; - private final Map connectionDetailsMap = new HashMap<>(); private final Set optimizers = new HashSet<>(); private final QueryValidator queryValidator = new DefaultQueryValidator(EntityDictionary.builder().build()); private Table.TableBuilder mainTableBuilder; private final Collection namespaceConfigs; + private final Function connectionLookup; public TableArgumentValidatorTest() { - this.connection = new ConnectionDetails(new HikariDataSource(), SQLDialectFactory.getDefaultDialect()); - this.connectionDetailsMap.put("mycon", this.connection); - this.connectionDetailsMap.put("SalesDBConnection", this.connection); + ConnectionDetails connection = new ConnectionDetails(new HikariDataSource(), SQLDialectFactory.getDefaultDialect()); + Map connectionDetailsMap = new HashMap<>(); + connectionDetailsMap.put("mycon", connection); + connectionDetailsMap.put("SalesDBConnection", connection); this.namespaceConfigs = Collections.singleton(NamespaceConfig.builder().name("namespace").build()); @@ -62,6 +65,8 @@ public TableArgumentValidatorTest() { .defaultValue("") .build()) ; + + connectionLookup = (name) -> connectionDetailsMap.getOrDefault(name, connection); } @Test @@ -79,7 +84,8 @@ public void testArgumentValues() { Set
tables = new HashSet<>(); tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify table arguments for table: namespace_MainTable. Value: '2.5' for Argument 'testArg' with Type 'INTEGER' is invalid.", e.getMessage()); @@ -100,7 +106,8 @@ public void testDefaultValueIsInValues() { Set
tables = new HashSet<>(); tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify table arguments for table: namespace_MainTable. Default Value: '5' for Argument 'testArg' with Type 'INTEGER' must match one of these values: [1, 2].", e.getMessage()); @@ -121,7 +128,8 @@ public void testDefaultValueMatchesType() { Set
tables = new HashSet<>(); tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify table arguments for table: namespace_MainTable. Default Value: '2.5' for Argument 'testArg' with Type 'INTEGER' is invalid.", e.getMessage()); @@ -137,7 +145,8 @@ public void testUndefinedTableArgsInTableSql() { Set
tables = new HashSet<>(); tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify table arguments for table: namespace_MainTable. Argument 'mainArg2' is not defined but found '{{$$table.args.mainArg2}}' in table's sql.", e.getMessage()); @@ -158,7 +167,8 @@ public void testUndefinedTableArgsInColumnDefinition() { Set
tables = new HashSet<>(); tables.add(mainTable); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify table arguments for table: namespace_MainTable. Argument 'mainArg2' is not defined but found '{{$$table.args.mainArg2}}' in definition of column: 'dim1'.", e.getMessage()); @@ -182,7 +192,8 @@ public void testUndefinedTableArgsInJoinExpressions() { .namespace("namespace") .build()); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify table arguments for table: namespace_MainTable. Argument 'mainArg2' is not defined but found '{{$$table.args.mainArg2}}' in definition of join: 'join'.", e.getMessage()); @@ -213,7 +224,8 @@ public void testMissingRequiredTableArgsForJoinTable() { .build()); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify table arguments for table: namespace_MainTable. Argument 'joinArg1' with type 'INTEGER' is not defined but is required by join table: namespace_JoinTable.", e.getMessage()); @@ -270,7 +282,8 @@ public void testTableArgsTypeMismatchForJoinTable() { .build()); MetaDataStore metaDataStore = new MetaDataStore(DefaultClassScanner.getInstance(), tables, this.namespaceConfigs, true); - Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connection, connectionDetailsMap, optimizers, queryValidator)); + QueryPlanMerger merger = new DefaultQueryPlanMerger(metaDataStore); + Exception e = assertThrows(IllegalStateException.class, () -> new SQLQueryEngine(metaDataStore, connectionLookup, optimizers, merger, queryValidator)); assertEquals("Failed to verify table arguments for table: namespace_MainTable. Argument type mismatch. Join table: 'namespace_JoinTable' has same Argument: 'mainArg1' with type 'TEXT'.", e.getMessage()); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/Player.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/Player.java index 308c9e7173..08b68c3576 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/Player.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/Player.java @@ -7,8 +7,11 @@ import com.yahoo.elide.annotation.Include; import com.yahoo.elide.datastores.aggregation.annotation.FriendlyName; +import com.yahoo.elide.datastores.aggregation.annotation.TableMeta; import lombok.Data; +import java.io.Serializable; + import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Table; @@ -19,8 +22,11 @@ @Entity @Include @Table(name = "players") +@TableMeta( + isHidden = true +) @Data -public class Player { +public class Player implements Serializable { @Id private long id; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerRanking.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerRanking.java index f9bf97a700..8774aa9fc8 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerRanking.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerRanking.java @@ -12,6 +12,7 @@ import javax.persistence.Entity; import javax.persistence.Id; +import javax.persistence.Table; /** * A root level entity for testing AggregationDataStore. @@ -20,6 +21,7 @@ @Include @Data @TableMeta(size = CardinalitySize.MEDIUM) +@Table(name = "playerRanking") public class PlayerRanking { @Id diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStats.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStats.java index 607baf7012..a02bbfaab8 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStats.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStats.java @@ -26,13 +26,17 @@ import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.VersionQuery; import com.yahoo.elide.datastores.aggregation.timegrains.Day; import com.yahoo.elide.datastores.aggregation.timegrains.Time; +import com.fasterxml.jackson.annotation.JsonIgnore; import example.dimensions.Country; +import example.dimensions.PlaceType; import example.dimensions.SubCountry; import lombok.EqualsAndHashCode; import lombok.Setter; import lombok.ToString; import javax.persistence.Column; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; import javax.persistence.Id; /** * A root level entity for testing AggregationDataStore. @@ -69,7 +73,7 @@ public class PlayerStats extends ParameterizedModel { /** * A metric. */ - private float dailyAverageScorePerPeriod; + private double dailyAverageScorePerPeriod; /** * A degenerate dimension. @@ -134,6 +138,9 @@ public class PlayerStats extends ParameterizedModel { @Setter private String countryIsInUsa; + private PlaceType placeType1; + private PlaceType placeType2; + @Id public String getId() { return id; @@ -153,6 +160,13 @@ public void setHighScore(final long highScore) { this.highScore = highScore; } + @JsonIgnore + @MetricFormula("MAX({{$highScore}})") + @ColumnMeta(isHidden = true, description = "hidden metric", category = "Score Category") + public long getHiddenHighScore() { + return fetch("hiddenHighScore", highScore); + } + @MetricFormula("MIN({{$lowScore}})") @ColumnMeta(description = "very low score", category = "Score Category", tags = {"PRIVATE"}) public long getLowScore() { @@ -164,16 +178,16 @@ public void setLowScore(final long lowScore) { } @MetricFormula(maker = DailyAverageScorePerPeriodMaker.class) - public float getDailyAverageScorePerPeriod() { + public double getDailyAverageScorePerPeriod() { return fetch("dailyAverageScorePerPeriod", dailyAverageScorePerPeriod); } - public void setDailyAverageScorePerPeriod(final float dailyAverageScorePerPeriod) { + public void setDailyAverageScorePerPeriod(final double dailyAverageScorePerPeriod) { this.dailyAverageScorePerPeriod = dailyAverageScorePerPeriod; } @FriendlyName - @ColumnMeta(values = {"Good", "OK", "Terrible"}, tags = {"PUBLIC"}, size = CardinalitySize.MEDIUM) + @ColumnMeta(values = {"Good", "OK", "Great", "Terrible"}, tags = {"PUBLIC"}, size = CardinalitySize.MEDIUM) public String getOverallRating() { return fetch("overallRating", overallRating); } @@ -214,7 +228,7 @@ public void setCountryUnSeats(int seats) { } @DimensionFormula("{{country.isoCode}}") - @ColumnMeta(values = {"HK", "USA"}) + @ColumnMeta(values = {"HKG", "USA"}) public String getCountryIsoCode() { return fetch("countryIsoCode", countryIsoCode); } @@ -319,6 +333,24 @@ public Time getRecordedDate() { public void setRecordedDate(final Time recordedDate) { this.recordedDate = recordedDate; } + + /** + * DO NOT put {@link Cardinality} annotation on this field. See + * + * @return the date of the player session. + */ + @JsonIgnore + @Temporal(grains = { + @TimeGrainDefinition(grain = TimeGrain.DAY, expression = DATE_FORMAT), + @TimeGrainDefinition(grain = TimeGrain.MONTH, expression = MONTH_FORMAT), + @TimeGrainDefinition(grain = TimeGrain.QUARTER, expression = QUARTER_FORMAT) + }, timeZone = "UTC") + @ColumnMeta(isHidden = true) + @DimensionFormula("{{$recordedDate}}") + public Time getHiddenRecordedDate() { + return fetch("hiddenRecordedDate", recordedDate); + } + /** * DO NOT put {@link Cardinality} annotation on this field. See * @@ -337,4 +369,24 @@ public void setUpdatedDate(final Day updatedDate) { public String getCountryIsInUsa() { return fetch("countryIsInUsa", countryIsInUsa); } + + @DimensionFormula("{{$place_type_ordinal}}") + @Enumerated(EnumType.ORDINAL) + public PlaceType getPlaceType1() { + return placeType1; + } + + public void setPlaceType1(PlaceType placeType) { + this.placeType1 = placeType; + } + + @DimensionFormula("{{$place_type_text}}") + @Enumerated(EnumType.STRING) + public PlaceType getPlaceType2() { + return placeType2; + } + + public void setPlaceType2(PlaceType placeType) { + this.placeType2 = placeType; + } } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStatsView.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStatsView.java index 9b21e7c70b..90ef12c362 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStatsView.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStatsView.java @@ -15,6 +15,8 @@ import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromSubquery; import lombok.Data; +import java.io.Serializable; + import javax.persistence.Id; /** @@ -28,7 +30,7 @@ @TableMeta(arguments = { @ArgumentDefinition(name = "rating", type = ValueType.TEXT), @ArgumentDefinition(name = "minScore", type = ValueType.INTEGER, defaultValue = "0")}) -public class PlayerStatsView { +public class PlayerStatsView implements Serializable { /** * PK. diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStatsWithView.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStatsWithView.java index c9d82d5034..d16b24434d 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStatsWithView.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/PlayerStatsWithView.java @@ -23,6 +23,7 @@ import lombok.Setter; import lombok.ToString; +import java.io.Serializable; import java.util.Date; import javax.persistence.Column; import javax.persistence.Id; @@ -34,7 +35,7 @@ @EqualsAndHashCode @ToString @FromTable(name = "playerStats") -public class PlayerStatsWithView { +public class PlayerStatsWithView implements Serializable { /** * PK. diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/TimeGrainDefinitions.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/TimeGrainDefinitions.java index d5a77ee15f..f39bacce78 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/TimeGrainDefinitions.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/TimeGrainDefinitions.java @@ -8,7 +8,7 @@ public class TimeGrainDefinitions { public static final String DATE_FORMAT = "PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd')"; - public static final String MONTH_FORMAT = "PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM'), 'yyyy-MM')"; + public static final String MONTH_FORMAT = "PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-01'), 'yyyy-MM-dd')"; public static final String QUARTER_FORMAT = - "PARSEDATETIME(CONCAT(FORMATDATETIME({{$$column.expr}}, 'yyyy-'), 3 * QUARTER({{$$column.expr}}) - 2), 'yyyy-MM')"; + "PARSEDATETIME(CONCAT(FORMATDATETIME({{$$column.expr}}, 'yyyy-'), LPAD(3 * QUARTER({{$$column.expr}}) - 2, 2, '0'), '-01'), 'yyyy-MM-dd')"; } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/VideoGame.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/VideoGame.java index 2c6549977b..5ffe22f15a 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/VideoGame.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/VideoGame.java @@ -14,6 +14,8 @@ import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable; import lombok.Setter; +import java.io.Serializable; + import javax.persistence.Column; import javax.persistence.Id; @@ -23,7 +25,7 @@ @Include @FromTable(name = "videoGames", dbConnectionName = "mycon") @ReadPermission(expression = "admin.user or player name filter") -public class VideoGame { +public class VideoGame implements Serializable { @Setter private Long id; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/Continent.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/Continent.java index 34ac00857c..da217c03ce 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/Continent.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/Continent.java @@ -10,6 +10,8 @@ import com.yahoo.elide.datastores.aggregation.annotation.TableMeta; import lombok.Data; +import java.io.Serializable; + import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Table; @@ -19,7 +21,7 @@ @Include @Table(name = "continents") @TableMeta(isFact = false, size = CardinalitySize.SMALL) -public class Continent { +public class Continent implements Serializable { @Id private String id; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/Country.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/Country.java index 69cdc17122..880ac10f5d 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/Country.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/Country.java @@ -13,6 +13,8 @@ import lombok.Data; import lombok.Setter; +import java.io.Serializable; + import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Id; @@ -28,7 +30,7 @@ @Include @Table(name = "countries") @TableMeta -public class Country { +public class Country implements Serializable { private String id; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/CountryView.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/CountryView.java index 28423c10c6..6bc7691191 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/CountryView.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/CountryView.java @@ -12,6 +12,8 @@ import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable; import lombok.Data; +import java.io.Serializable; + import javax.persistence.Column; /** @@ -20,7 +22,7 @@ @Data @Include(rootLevel = false) @FromTable(name = "countries") -public class CountryView { +public class CountryView implements Serializable { @Column(name = "id") private String countryId; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/CountryViewNested.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/CountryViewNested.java index 727eb0036d..868bcea325 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/CountryViewNested.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/CountryViewNested.java @@ -10,6 +10,8 @@ import com.yahoo.elide.datastores.aggregation.queryengines.sql.annotation.FromTable; import lombok.Data; +import java.io.Serializable; + import javax.persistence.Column; import javax.persistence.Id; @@ -19,7 +21,7 @@ @Data @Include(rootLevel = false) @FromTable(name = "countries") -public class CountryViewNested { +public class CountryViewNested implements Serializable { private String id; private String isoCode; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/PlaceType.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/PlaceType.java new file mode 100644 index 0000000000..6b1b1c1d0d --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/PlaceType.java @@ -0,0 +1,15 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.dimensions; + +public enum PlaceType { + COUNTRY, + STATE, + COUNTY, + TOWN, + PLACE +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/RegionDetails.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/RegionDetails.java index e313309f11..5c905d917e 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/RegionDetails.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/RegionDetails.java @@ -16,6 +16,7 @@ import lombok.EqualsAndHashCode; import lombok.ToString; +import javax.persistence.Column; import javax.persistence.Id; /** @@ -32,6 +33,8 @@ public class RegionDetails { private String id; private String region; + private PlaceType placeType; + private PlaceType ordinalPlaceType; @Id public String getId() { @@ -52,4 +55,22 @@ public String getRegion() { public void setRegion(String region) { this.region = region; } + + @Column(name = "type") + public PlaceType getPlaceType() { + return placeType; + } + + public void setPlaceType(PlaceType placeType) { + this.placeType = placeType; + } + + @Column(name = "ordinal_type") + public PlaceType getOrdinalPlaceType() { + return ordinalPlaceType; + } + + public void setOrdinalPlaceType(PlaceType placeType) { + this.ordinalPlaceType = ordinalPlaceType; + } } diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/SubCountry.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/SubCountry.java index c2783f89e0..e5f620f789 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/SubCountry.java +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/dimensions/SubCountry.java @@ -10,6 +10,8 @@ import org.hibernate.annotations.Subselect; import lombok.Data; +import java.io.Serializable; + import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Id; @@ -21,7 +23,7 @@ @Entity @Include @Subselect(value = "select * from countries") -public class SubCountry { +public class SubCountry implements Serializable { private String id; diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/metrics/MetricRatio.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/metrics/MetricRatio.java new file mode 100644 index 0000000000..eca576c03a --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/metrics/MetricRatio.java @@ -0,0 +1,42 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.metrics; + +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.request.Argument; +import com.yahoo.elide.datastores.aggregation.metadata.models.Metric; +import com.yahoo.elide.datastores.aggregation.query.MetricProjection; +import com.yahoo.elide.datastores.aggregation.query.MetricProjectionMaker; +import com.yahoo.elide.datastores.aggregation.queryengines.sql.query.SQLMetricProjection; + +import java.util.Map; + +/** + * Metric ratio cookbook example. + */ +public class MetricRatio implements MetricProjectionMaker { + @Override + public MetricProjection make(Metric metric, String alias, Map arguments) { + + Argument numerator = arguments.get("numerator"); + Argument denominator = arguments.get("denominator"); + + if (numerator == null || denominator == null) { + throw new BadRequestException("'numerator' and 'denominator' arguments are required for " + + metric.getName()); + } + + return SQLMetricProjection.builder() + .alias(alias) + .arguments(arguments) + .name(metric.getName()) + .expression("{{" + numerator.getValue() + "}} / {{" + denominator.getValue() + "}}") + .valueType(metric.getValueType()) + .columnType(metric.getColumnType()) + .build(); + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/java/example/tables/OrderDetailsMaker.java b/elide-datastore/elide-datastore-aggregation/src/test/java/example/tables/OrderDetailsMaker.java new file mode 100644 index 0000000000..d4bfa8d341 --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/java/example/tables/OrderDetailsMaker.java @@ -0,0 +1,31 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.tables; + +import com.yahoo.elide.datastores.aggregation.query.Query; +import com.yahoo.elide.datastores.aggregation.query.TableSQLMaker; + +public class OrderDetailsMaker implements TableSQLMaker { + /** + * Creates a subquery that mirrors the following table: + * + * CREATE TABLE IF NOT EXISTS order_details + * ( + * order_id VARCHAR(255) NOT NULL, + * customer_id VARCHAR(255), + * order_total NUMERIC(10,2), + * created_on DATETIME, + * PRIMARY KEY (order_id) + * ); + * @param clientQuery the client query. + * @return SQL query + */ + @Override + public String make(Query clientQuery) { + return "SELECT order_id, customer_id, order_total, created_on FROM order_details"; + } +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/OrderDetailsMakerExample.hjson b/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/OrderDetailsMakerExample.hjson new file mode 100644 index 0000000000..da7e4502ee --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/OrderDetailsMakerExample.hjson @@ -0,0 +1,247 @@ +{ + tables: + [ + { + name: orderDetails2 + maker: example.tables.OrderDetailsMaker + dbConnectionName: SalesDBConnection + cardinality: large + readAccess: guest user + namespace: SalesNamespace + filterTemplate : deliveryTime>={{start}};deliveryTime<{{end}} + arguments: + [ + { // An argument that can be used to divide orderTotal to convert orders in Millions, Thousands, etc. + name: denominator + type: DECIMAL + default: 1 + } + ] + joins: + [ + { + name: customer + to: customerDetails + namespace: SalesNamespace + type: Left + // References Physical Columns + definition: '{{$customer_id}} = {{ customer.$id}}' + } + { + name: delivery + to: deliveryDetails + namespace: SalesNamespace + kind: toOne + // References Logical Columns, multiple join condition + definition: ''' + {{ orderId}} = {{delivery.orderId}} AND + {{ delivery.$delivered_on }} >= '{{ $$table.args.start }}' AND + {{ delivery.$delivered_on }} < '{{ $$table.args.end }}' + ''' + } + ] + measures: + [ + { + name: orderRatio + type: DECIMAL + maker: example.metrics.MetricRatio + arguments: + [ + { + name: numerator + type: TEXT + values: ['orderTotal', 'orderMax'] + default: 'orderTotal' + } + { + name: denominator + type: TEXT + values: ['orderTotal', 'orderMax'] + default: 'orderTotal' + } + ] + } + { + name: orderTotal + type: DECIMAL + // TODO : Use Arguments + definition: 'SUM({{ $order_total }})' + readAccess: admin.user + arguments: + [ + { // An argument that can be used to divide orderTotal to convert orders in Millions, Thousands, etc. + name: precision + type: DECIMAL + default: 0.00 + } + ] + }, + { + name: orderMax + type: DECIMAL + // TODO : Use Arguments + maker: example.metrics.SalesViewOrderMax + readAccess: admin.user + arguments: + [ + { // An argument that can be used to divide orderTotal to convert orders in Millions, Thousands, etc. + name: precision + type: DECIMAL + default: 0.00 + } + ] + } + ] + dimensions: + [ + { + name: orderId + type: TEXT + definition: '{{ $order_id }}' + readAccess: guest user + } + { + name: courierName + type: TEXT + definition: '{{delivery.$courier_name}}' + readAccess: operator + } + { + name: customerRegion + type: TEXT + definition: '{{customer.customerRegion}}' + readAccess: operator + cardinality: small + } + //Tests a reference to an enum Java dimension stored as VARCHAR coerced to a string. + { + name: customerRegionType1 + type: ENUM_TEXT + definition: '{{customer.region.placeType}}' + readAccess: guest user + } + //Tests a reference to an enum Java dimension stored as INT coerced to the enum. + { + name: customerRegionType2 + type: ENUM_ORDINAL + definition: '{{customer.region.ordinalPlaceType}}' + readAccess: guest user + values: ['COUNTRY', 'STATE', 'COUNTY', 'TOWN', 'PLACE'] + } + { + name: customerRegionRegion + type: TEXT + definition: '{{customer.region.region}}' + readAccess: operator + tableSource: { + table: regionDetails + column: region + } + } + { + name: zipCode + type: INTEGER + definition: '{{zipCodeHidden}}' + readAccess: operator + } + + { + name: zipCodeHidden + type: INTEGER + hidden: true + definition: '{{customer.zipCode}}' + readAccess: operator + } + { + name: orderTime + type: TIME + // Physical Column Reference in same table + definition: '{{$created_on}}' + readAccess: guest user + grains: + [ + { + type: MONTH + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-01'), 'yyyy-MM-dd') + }, + { + type: SECOND + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd HH:mm:ss'), 'yyyy-MM-dd HH:mm:ss') + }, + { + type: DAY + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + } + ] + } + { + name: deliveryTime + type: TIME + // Physical Column Reference in referred table + definition: '{{delivery.$delivered_on}}' + readAccess: guest user + grains: + [{ + type: SECOND + }] + } + { + name: deliveryDate + type: TIME + // Logical Column Reference in referred table, which references Physical column in referred table + definition: '{{delivery.time}}' + readAccess: guest user + grains: + [{ + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd'), 'yyyy-MM-dd') + }] + } + { + name: deliveryMonth + type: TIME + // Logical Column Reference in referred table, which references another Logical column in referred table, which references another Logical column in referred table, which references Physical column in referred table + definition: '{{delivery.month}}' + readAccess: guest user + grains: + [{ + type: MONTH + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-01'), 'yyyy-MM-dd') + }] + } + { + name: deliveryHour + type: TIME + // Logical Column Reference in same table, which references Physical column in referred table + definition: '{{deliveryTime}}' + readAccess: guest user + grains: + [{ + type: HOUR + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-dd HH'), 'yyyy-MM-dd HH') + }] + } + { + name: deliveryYear + type: TIME + // Logical Column Reference in same table, which references another Logical Column in referred table, which references another Logical column in referred table, which references another Logical column in referred table, which references Physical column in referred table + definition: '{{deliveryMonth}}' + readAccess: guest user + grains: + [{ + type: YEAR + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-01-01'), 'yyyy-MM-dd') + }] + filterTemplate: 'deliveryYear=={{deliveryYear}}' + } + { + name: deliveryDefault + type: TIME + // Logical Column Reference in same table, which references another Logical Column in referred table, which references another Logical column in referred table, which references another Logical column in referred table, which references Physical column in referred table + definition: '{{delivery.time}}' + readAccess: guest user + } + ] + } + ] +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/SalesPerformance.hjson b/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/SalesPerformance.hjson new file mode 100644 index 0000000000..0b953e5c0f --- /dev/null +++ b/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/SalesPerformance.hjson @@ -0,0 +1,29 @@ +{ + tables: + [ + { + name: salesPerformance + table: sales_performance + dbConnectionName: SalesDBConnection + cardinality: small + hidden:true + namespace: SalesNamespace + measures: + [ + { + name: totalSales + type: DECIMAL + definition: 'SUM({{ $sales }})' + } + ] + dimensions: + [ + { + name: employeeId + type: INTEGER + definition: '{{ $employee_id }}' + } + ] + } + ] +} diff --git a/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/SalesView.hjson b/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/SalesView.hjson index d6c407fdb1..6bb8f46c41 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/SalesView.hjson +++ b/elide-datastore/elide-datastore-aggregation/src/test/resources/configs/models/tables/SalesView.hjson @@ -42,6 +42,26 @@ ] measures: [ + { + name: orderRatio + type: DECIMAL + maker: example.metrics.MetricRatio + arguments: + [ + { + name: numerator + type: TEXT + values: ['orderTotal', 'orderMax'] + default: 'orderTotal' + } + { + name: denominator + type: TEXT + values: ['orderTotal', 'orderMax'] + default: 'orderTotal' + } + ] + } { name: orderTotal type: DECIMAL @@ -94,6 +114,21 @@ readAccess: operator cardinality: small } + //Tests a reference to an enum Java dimension stored as VARCHAR coerced to a string. + { + name: customerRegionType1 + type: ENUM_TEXT + definition: '{{customer.region.placeType}}' + readAccess: guest user + } + //Tests a reference to an enum Java dimension stored as INT coerced to the enum. + { + name: customerRegionType2 + type: ENUM_ORDINAL + definition: '{{customer.region.ordinalPlaceType}}' + readAccess: guest user + values: ['COUNTRY', 'STATE', 'COUNTY', 'TOWN', 'PLACE'] + } { name: customerRegionRegion type: TEXT @@ -107,6 +142,14 @@ { name: zipCode type: INTEGER + definition: '{{zipCodeHidden}}' + readAccess: operator + } + + { + name: zipCodeHidden + type: INTEGER + hidden: true definition: '{{customer.zipCode}}' readAccess: operator } @@ -120,7 +163,7 @@ [ { type: MONTH - sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM'), 'yyyy-MM') + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-01'), 'yyyy-MM-dd') }, { type: SECOND @@ -163,7 +206,7 @@ grains: [{ type: MONTH - sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM'), 'yyyy-MM') + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-01'), 'yyyy-MM-dd') }] } { @@ -187,7 +230,7 @@ grains: [{ type: YEAR - sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy'), 'yyyy') + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-01-01'), 'yyyy-MM-dd') }] filterTemplate: 'deliveryYear=={{deliveryYear}}' } @@ -199,10 +242,10 @@ readAccess: guest user } ] - } - { + } { name: customerDetails table: customer_details + hidden: true dbConnectionName: SalesDBConnection cardinality: small readAccess: guest user @@ -227,7 +270,7 @@ { name: zipCode type: INTEGER - definition: '{{$zip_code}}' + definition: '{{[$zip code]}}' readAccess: guest user } { @@ -289,7 +332,7 @@ grains: [{ type: MONTH - sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM'), 'yyyy-MM') + sql: PARSEDATETIME(FORMATDATETIME({{$$column.expr}}, 'yyyy-MM-01'), 'yyyy-MM-dd') }] } ] diff --git a/elide-datastore/elide-datastore-aggregation/src/test/resources/graphql/responses/testGraphQLSchema.json b/elide-datastore/elide-datastore-aggregation/src/test/resources/graphql/responses/testGraphQLSchema.json index 4ba2f84747..82ecc7d86f 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/resources/graphql/responses/testGraphQLSchema.json +++ b/elide-datastore/elide-datastore-aggregation/src/test/resources/graphql/responses/testGraphQLSchema.json @@ -39,14 +39,14 @@ { "name": "highScore", "type": { - "name": "Int", + "name": "BigInteger", "fields": null } }, { "name": "lowScore", "type": { - "name": "Int", + "name": "BigInteger", "fields": null } }, diff --git a/elide-datastore/elide-datastore-aggregation/src/test/resources/prepare_SalesDB_tables.sql b/elide-datastore/elide-datastore-aggregation/src/test/resources/prepare_SalesDB_tables.sql index de96f92ff9..536151e1b8 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/resources/prepare_SalesDB_tables.sql +++ b/elide-datastore/elide-datastore-aggregation/src/test/resources/prepare_SalesDB_tables.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS customer_details ( id VARCHAR(255) NOT NULL, name VARCHAR(255), - zip_code INT, + `zip code` INT, PRIMARY KEY (id) ); @@ -10,16 +10,27 @@ CREATE TABLE IF NOT EXISTS region_details ( zip_code INT NOT NULL, region VARCHAR(255) NOT NULL, + type VARCHAR(255), + ordinal_type INT, PRIMARY KEY (zip_code) ); +CREATE TABLE IF NOT EXISTS sales_performance +( + employee_id VARCHAR(255) NOT NULL, + sales INT, + PRIMARY KEY (employee_id) +); + INSERT INTO customer_details SELECT 'cust1', 'foo1', 20166 from dual WHERE NOT EXISTS(SELECT * FROM customer_details WHERE id = 'cust1'); INSERT INTO customer_details SELECT 'cust2', 'foo2', 10002 from dual WHERE NOT EXISTS(SELECT * FROM customer_details WHERE id = 'cust2'); INSERT INTO customer_details SELECT 'cust3', 'foo3', 20170 from dual WHERE NOT EXISTS(SELECT * FROM customer_details WHERE id = 'cust3'); +INSERT INTO customer_details SELECT 'cust4', 'foo4', 0 from dual WHERE NOT EXISTS(SELECT * FROM customer_details WHERE id = 'cust4'); -INSERT INTO region_details SELECT 20166, 'Virginia' from dual WHERE NOT EXISTS(SELECT * FROM region_details WHERE zip_code = 20166); -INSERT INTO region_details SELECT 20170, 'Virginia' from dual WHERE NOT EXISTS(SELECT * FROM region_details WHERE zip_code = 20170); -INSERT INTO region_details SELECT 10002, 'NewYork' from dual WHERE NOT EXISTS(SELECT * FROM region_details WHERE zip_code = 10002); +INSERT INTO region_details SELECT 20166, 'Virginia', 'STATE', 1 from dual WHERE NOT EXISTS(SELECT * FROM region_details WHERE zip_code = 20166); +INSERT INTO region_details SELECT 20170, 'Virginia', 'STATE', 1 from dual WHERE NOT EXISTS(SELECT * FROM region_details WHERE zip_code = 20170); +INSERT INTO region_details SELECT 10002, 'NewYork', 'STATE', 1 from dual WHERE NOT EXISTS(SELECT * FROM region_details WHERE zip_code = 10002); +INSERT INTO region_details SELECT 0, 'NewYork', null, null from dual WHERE NOT EXISTS(SELECT * FROM region_details WHERE zip_code = 10002); CREATE TABLE IF NOT EXISTS order_details ( @@ -37,6 +48,7 @@ INSERT INTO order_details SELECT 'order-2a', 'cust2', 17.82, '2020-08-25 16:30:1 INSERT INTO order_details SELECT 'order-2b', 'cust2', 43.61, '2020-08-26 16:30:11' WHERE NOT EXISTS(SELECT * FROM order_details WHERE order_id = 'order-2b'); INSERT INTO order_details SELECT 'order-3a', 'cust3', 9.35, '2020-08-26 16:30:11' WHERE NOT EXISTS(SELECT * FROM order_details WHERE order_id = 'order-3a'); INSERT INTO order_details SELECT 'order-3b', 'cust3', 78.87, '2020-09-09 16:30:11' WHERE NOT EXISTS(SELECT * FROM order_details WHERE order_id = 'order-3b'); +INSERT INTO order_details SELECT 'order-null-enum', 'cust4', 78.87, '2020-09-09 16:30:11' WHERE NOT EXISTS(SELECT * FROM order_details WHERE order_id = 'order-null-enum'); CREATE TABLE IF NOT EXISTS delivery_details ( @@ -55,3 +67,4 @@ INSERT INTO delivery_details SELECT 'del-2a', 'order-2a', 1112021844256, 'FEDEX' INSERT INTO delivery_details SELECT 'del-2b', 'order-2b', 2602534554, 'UPS', '2020-08-31 16:30:11' WHERE NOT EXISTS(SELECT * FROM delivery_details WHERE delivery_id = 'del-2b'); INSERT INTO delivery_details SELECT 'del-3a', 'order-3a', 2602452494, 'UPS', '2020-08-31 16:30:11' WHERE NOT EXISTS(SELECT * FROM delivery_details WHERE delivery_id = 'del-3a'); INSERT INTO delivery_details SELECT 'del-3b', 'order-3b', 2602475626, 'UPS', '2020-09-13 16:30:11' WHERE NOT EXISTS(SELECT * FROM delivery_details WHERE delivery_id = 'del-3b'); +INSERT INTO delivery_details SELECT 'del-4', 'order-null-enum', 2602475626, 'UPS', '2020-09-13 16:30:11' WHERE NOT EXISTS(SELECT * FROM delivery_details WHERE delivery_id = 'del-4'); diff --git a/elide-datastore/elide-datastore-aggregation/src/test/resources/prepare_tables.sql b/elide-datastore/elide-datastore-aggregation/src/test/resources/prepare_tables.sql index 60c42a2252..1a3a376396 100644 --- a/elide-datastore/elide-datastore-aggregation/src/test/resources/prepare_tables.sql +++ b/elide-datastore/elide-datastore-aggregation/src/test/resources/prepare_tables.sql @@ -9,12 +9,14 @@ CREATE TABLE IF NOT EXISTS playerStats player_id BIGINT, player2_id BIGINT, recordedDate DATETIME, - updatedDate DATETIME + updatedDate DATETIME, + place_type_ordinal BIGINT, + place_type_text VARCHAR(255) ); TRUNCATE TABLE playerStats; -INSERT INTO playerStats VALUES (1, 1234, 35, 'Good', '840', '840', 1, 2, '2019-07-12 00:00:00', '2019-10-12 00:00:00'); -INSERT INTO playerStats VALUES (2, 2412, 241, 'Great', '840', '840', 2, 3, '2019-07-11 00:00:00', '2020-07-12 00:00:00'); -INSERT INTO playerStats VALUES (3, 1000, 72, 'Good', '344', '344', 3, 1, '2019-07-13 00:00:00', '2020-01-12 00:00:00'); +INSERT INTO playerStats VALUES (1, 1234, 35, 'Good', '840', '840', 1, 2, '2019-07-12 00:00:00', '2019-10-12 00:00:00', 1, 'STATE'); +INSERT INTO playerStats VALUES (2, 3147483647, 241, 'Great', '840', '840', 2, 3, '2019-07-11 00:00:00', '2020-07-12 00:00:00', 1, 'STATE'); +INSERT INTO playerStats VALUES (3, 1000, 72, 'Good', '344', '344', 3, 1, '2019-07-13 00:00:00', '2020-01-12 00:00:00', 1, 'STATE'); CREATE TABLE IF NOT EXISTS countries diff --git a/elide-datastore/elide-datastore-inmemorydb/pom.xml b/elide-datastore/elide-datastore-inmemorydb/pom.xml index 7727a1da56..770fcc0708 100644 --- a/elide-datastore/elide-datastore-inmemorydb/pom.xml +++ b/elide-datastore/elide-datastore-inmemorydb/pom.xml @@ -14,7 +14,7 @@ com.yahoo.elide elide-datastore-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -52,7 +52,7 @@ com.yahoo.elide elide-integration-tests - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT test-jar test diff --git a/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/MetaIT.java b/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/MetaIT.java new file mode 100644 index 0000000000..2bc22a8881 --- /dev/null +++ b/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/MetaIT.java @@ -0,0 +1,62 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.inmemory; + +import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.*; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + +import com.yahoo.elide.core.datastore.test.DataStoreTestHarness; +import com.yahoo.elide.initialization.IntegrationTest; + +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.Test; + +public class MetaIT extends IntegrationTest { + @Override + protected DataStoreTestHarness createHarness() { + return new WithMetaInMemoryDataStoreHarness(); + } + + @Test + public void testEmptyFetch() { + given() + .when() + .get("/widget") + .then() + .statusCode(HttpStatus.SC_OK) + .body("meta.foobar", equalTo(123)); + } + + @Test + public void testCreateAndFetch() { + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body(datum( + resource( + type("widget"), + id("1") + ) + ).toJSON()) + .when() + .post("/widget") + .then() + .statusCode(HttpStatus.SC_CREATED) + .body("data.meta.foo", equalTo("bar")); + + given() + .when() + .get("/widget?page[totals]") + .then() + .statusCode(HttpStatus.SC_OK) + .body("data[0].meta.foo", equalTo("bar")) + .body("meta.foobar", equalTo(123)) + .body("meta.page.totalRecords", equalTo(1)); + } +} diff --git a/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/WithMetaInMemoryDataStoreHarness.java b/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/WithMetaInMemoryDataStoreHarness.java new file mode 100644 index 0000000000..67240046b3 --- /dev/null +++ b/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/datastores/inmemory/WithMetaInMemoryDataStoreHarness.java @@ -0,0 +1,68 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.inmemory; + +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.datastore.test.DataStoreTestHarness; +import com.yahoo.elide.core.datastore.wrapped.TransactionWrapper; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.example.beans.meta.Widget; + +import java.util.Set; + +/** + * Harness that creates custom in memory store that sets metadata on the request scope. + */ +public class WithMetaInMemoryDataStoreHarness implements DataStoreTestHarness { + + HashMapDataStore wrappedStore = new HashMapDataStore(Set.of(Widget.class)); + + @Override + public DataStore getDataStore() { + return new CustomStore(); + } + + @Override + public void cleanseTestData() { + wrappedStore.cleanseTestData(); + } + + class CustomTransaction extends TransactionWrapper { + public CustomTransaction() { + super(wrappedStore.beginTransaction()); + } + + @Override + public DataStoreIterable loadObjects(EntityProjection projection, RequestScope scope) { + scope.setMetadataField("foobar", 123); + return super.loadObjects(projection, scope); + } + } + + class CustomStore implements DataStore { + + @Override + public void populateEntityDictionary(EntityDictionary dictionary) { + wrappedStore.populateEntityDictionary(dictionary); + } + + @Override + public DataStoreTransaction beginTransaction() { + return new CustomTransaction(); + } + + @Override + public DataStoreTransaction beginReadTransaction() { + return new CustomTransaction(); + } + } +} diff --git a/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/example/beans/meta/Widget.java b/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/example/beans/meta/Widget.java new file mode 100644 index 0000000000..9e35a50975 --- /dev/null +++ b/elide-datastore/elide-datastore-inmemorydb/src/test/java/com/yahoo/elide/example/beans/meta/Widget.java @@ -0,0 +1,39 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.example.beans.meta; + +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.jsonapi.document.processors.WithMetadata; + +import java.util.Optional; +import java.util.Set; +import javax.persistence.Id; + +@Include +public class Widget implements WithMetadata { + @Id + private String id; + + @Override + public void setMetadataField(String property, Object value) { + //NOOP + } + + @Override + public Optional getMetadataField(String property) { + if (property.equals("foo")) { + return Optional.of("bar"); + } + + return Optional.empty(); + } + + @Override + public Set getMetadataFields() { + return Set.of("foo"); + } +} diff --git a/elide-datastore/elide-datastore-jms/pom.xml b/elide-datastore/elide-datastore-jms/pom.xml index 8332c970fe..14f7fec9c5 100644 --- a/elide-datastore/elide-datastore-jms/pom.xml +++ b/elide-datastore/elide-datastore-jms/pom.xml @@ -6,7 +6,7 @@ com.yahoo.elide elide-datastore-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -36,6 +36,12 @@ com.yahoo.elide elide-core + + + com.yahoo.elide + elide-graphql + + com.fasterxml.jackson.core @@ -46,6 +52,13 @@ jackson-core + + + com.google.code.gson + gson + 2.9.1 + + javax.jms javax.jms-api @@ -71,17 +84,23 @@ test + + io.rest-assured + rest-assured + test + + org.apache.activemq artemis-server - 2.18.0 + 2.26.0 test org.apache.activemq artemis-jms-client-all - 2.18.0 + 2.26.0 test @@ -90,6 +109,58 @@ javax.persistence-api provided + + org.eclipse.jetty + jetty-servlet + test + + + + + org.glassfish.jersey.containers + jersey-container-servlet + test + + + + org.glassfish.jersey.inject + jersey-hk2 + test + + + + + org.eclipse.jetty.websocket + websocket-jetty-server + ${version.jetty} + test + + + + org.eclipse.jetty.websocket + websocket-jetty-client + ${version.jetty} + test + + + + org.eclipse.jetty.websocket + websocket-servlet + ${version.jetty} + test + + + + org.eclipse.jetty.websocket + websocket-javax-server + ${version.jetty} + test + + + com.yahoo.elide + elide-test-helpers + test + diff --git a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/JMSDataStore.java b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/JMSDataStore.java index f14eb0f3c4..37e34a7272 100644 --- a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/JMSDataStore.java +++ b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/JMSDataStore.java @@ -6,12 +6,25 @@ package com.yahoo.elide.datastores.jms; +import static com.yahoo.elide.graphql.subscriptions.SubscriptionModelBuilder.TOPIC_ARGUMENT; +import com.yahoo.elide.annotation.Include; import com.yahoo.elide.core.datastore.DataStore; import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.ArgumentType; import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.hooks.SubscriptionFieldSerde; +import com.yahoo.elide.graphql.subscriptions.hooks.TopicType; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import javax.jms.ConnectionFactory; import javax.jms.JMSContext; @@ -19,41 +32,100 @@ * Elide datastore that reads models from JMS message topics. */ public class JMSDataStore implements DataStore { - private Set> models; - private ConnectionFactory connectionFactory; - private EntityDictionary dictionary; + //Maps supported subscription models to whether or not they support topics. + protected Map, Boolean> models; + + protected ConnectionFactory connectionFactory; + protected EntityDictionary dictionary; + + protected long timeoutInMs = -1; + + //For serializing Elide models to topics. + protected Gson gson; /** * Constructor. * @param models The set of models to manage. * @param connectionFactory The JMS connection factory. * @param dictionary The entity dictionary. + * @param timeoutInMs request timeout in milliseconds. 0 means immediate. -1 means no timeout. */ public JMSDataStore( Set> models, ConnectionFactory connectionFactory, - EntityDictionary dictionary) { - this.models = models; + EntityDictionary dictionary, + long timeoutInMs + ) { + this.models = models.stream().collect(Collectors.toMap( + (model) -> model, + (model) -> { + Subscription subscription = model.getAnnotation(Subscription.class); + return subscription != null + && subscription.operations() != null + && subscription.operations().length > 0; + } + )); + this.connectionFactory = connectionFactory; this.dictionary = dictionary; + this.timeoutInMs = timeoutInMs; + + GsonBuilder gsonBuilder = new GsonBuilder(); + CoerceUtil.getSerdes().forEach((cls, serde) -> { + gsonBuilder.registerTypeAdapter(cls, new SubscriptionFieldSerde(serde)); + }); + gson = gsonBuilder.create(); + } + + /** + * Constructor. + * @param scanner to scan for subscription annotations. + * @param connectionFactory The JMS connection factory. + * @param dictionary The entity dictionary. + * @param timeoutInMs request timeout in milliseconds. 0 means immediate. -1 means no timeout. + */ + public JMSDataStore( + ClassScanner scanner, + ConnectionFactory connectionFactory, + EntityDictionary dictionary, + long timeoutInMs + ) { + this( + scanner.getAnnotatedClasses(Subscription.class, Include.class).stream() + .map(ClassType::of) + .collect(Collectors.toSet()), + connectionFactory, dictionary, timeoutInMs); } @Override public void populateEntityDictionary(EntityDictionary dictionary) { - for (Type model : models) { + for (Type model : models.keySet()) { + + Boolean supportsTopics = models.get(model); + dictionary.bindEntity(model); + + if (supportsTopics) { + //Add topic type argument to each model. + dictionary.addArgumentToEntity(model, ArgumentType + .builder() + .name(TOPIC_ARGUMENT) + .defaultValue(TopicType.ADDED) + .type(ClassType.of(TopicType.class)) + .build()); + } } } @Override public DataStoreTransaction beginTransaction() { JMSContext context = connectionFactory.createContext(); - return new JMSDataStoreTransaction(context, dictionary); + return new JMSDataStoreTransaction(context, dictionary, gson, timeoutInMs); } @Override public DataStoreTransaction beginReadTransaction() { JMSContext context = connectionFactory.createContext(); - return new JMSDataStoreTransaction(context, dictionary); + return new JMSDataStoreTransaction(context, dictionary, gson, timeoutInMs); } } diff --git a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/JMSDataStoreTransaction.java b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/JMSDataStoreTransaction.java index 10eea8ced2..0fbb17329d 100644 --- a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/JMSDataStoreTransaction.java +++ b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/JMSDataStoreTransaction.java @@ -6,35 +6,52 @@ package com.yahoo.elide.datastores.jms; +import static com.yahoo.elide.graphql.subscriptions.SubscriptionModelBuilder.TOPIC_ARGUMENT; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.request.Argument; import com.yahoo.elide.core.request.EntityProjection; -import com.google.common.base.Preconditions; +import com.yahoo.elide.graphql.subscriptions.hooks.TopicType; + +import com.google.gson.Gson; +import lombok.extern.slf4j.Slf4j; import java.io.IOException; -import java.util.Optional; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import javax.jms.Destination; import javax.jms.JMSConsumer; import javax.jms.JMSContext; +import javax.jms.JMSRuntimeException; /** * Data store transaction for reading Elide models from JMS topics. */ +@Slf4j public class JMSDataStoreTransaction implements DataStoreTransaction { private JMSContext context; private EntityDictionary dictionary; + private Gson gson; + private long timeoutInMs; + private List consumers; /** * Constructor. * @param context JMS Context * @param dictionary Elide Entity Dictionary + * @param gson Gson serializer to convert Elide models to topic messages. + * @param timeoutInMs request timeout in milliseconds. 0 means immediate. -1 means no timeout. */ - public JMSDataStoreTransaction(JMSContext context, EntityDictionary dictionary) { + public JMSDataStoreTransaction(JMSContext context, EntityDictionary dictionary, Gson gson, long timeoutInMs) { this.context = context; + this.gson = gson; this.dictionary = dictionary; + this.timeoutInMs = timeoutInMs; + this.consumers = new ArrayList<>(); } @Override @@ -63,50 +80,54 @@ public void createObject(T entity, RequestScope scope) { } @Override - public Iterable loadObjects(EntityProjection entityProjection, RequestScope scope) { - Preconditions.checkState(entityProjection.getArguments().size() == 1); - - Argument argument = entityProjection.getArguments().iterator().next(); - TopicType topicType = (TopicType) argument.getValue(); + public DataStoreIterable loadObjects(EntityProjection entityProjection, RequestScope scope) { + TopicType topicType = getTopicType(entityProjection); String topicName = topicType.toTopicName(entityProjection.getType(), dictionary); Destination destination = context.createTopic(topicName); JMSConsumer consumer = context.createConsumer(destination); - return new MessageIterator<>( + context.start(); + + consumers.add(consumer); + + return new MessageIterable<>( consumer, - ((SubscriptionRequestScope) scope).getTimeoutInMs(), - new MessageDeserializer<>(entityProjection.getType()) + timeoutInMs, + new MessageDeserializer<>(entityProjection.getType(), gson) ); } @Override public void cancel(RequestScope scope) { - context.stop(); + shutdown(); } @Override public void close() throws IOException { - context.stop(); - context.close(); + shutdown(); } - @Override - public FeatureSupport supportsFiltering(RequestScope scope, Optional parent, EntityProjection projection) { - //Delegate to in-memory filtering - return FeatureSupport.NONE; + private void shutdown() { + try { + consumers.forEach(JMSConsumer::close); + context.stop(); + context.close(); + } catch (JMSRuntimeException e) { + log.debug("Exception throws while closing context: {}", e.getMessage()); + } } - @Override - public boolean supportsSorting(RequestScope scope, Optional parent, EntityProjection projection) { - //Delegate to in-memory sorting - return false; - } + protected TopicType getTopicType(EntityProjection projection) { + Set arguments = projection.getArguments(); - @Override - public boolean supportsPagination(RequestScope scope, Optional parent, EntityProjection projection) { - //Delegate to in-memory pagination - return false; + for (Argument argument: arguments) { + if (argument.getName().equals(TOPIC_ARGUMENT)) { + return (TopicType) argument.getValue(); + } + } + + return TopicType.CUSTOM; } } diff --git a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageDeserializer.java b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageDeserializer.java index a20658359f..c4e67dbf7c 100644 --- a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageDeserializer.java +++ b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageDeserializer.java @@ -8,8 +8,7 @@ import com.yahoo.elide.core.exceptions.InternalServerErrorException; import com.yahoo.elide.core.type.Type; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; import java.util.function.Function; import javax.jms.JMSException; @@ -21,24 +20,24 @@ * @param elide model type. */ public class MessageDeserializer implements Function { - - private ObjectMapper mapper; - Type type; + private Type type; + private Gson gson; /** * Constructor. * @param type The type to deserialize to. + * @param gson Gson serializer to convert Elide models to topic messages. */ - public MessageDeserializer(Type type) { + public MessageDeserializer(Type type, Gson gson) { this.type = type; - this.mapper = new ObjectMapper(); + this.gson = gson; } @Override public T apply(Message message) { try { - return (T) mapper.readValue(((TextMessage) message).getText(), type.getUnderlyingClass().get()); - } catch (JsonProcessingException | JMSException e) { + return (T) gson.fromJson(((TextMessage) message).getText(), type.getUnderlyingClass().get()); + } catch (JMSException e) { throw new InternalServerErrorException(e); } } diff --git a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageIterator.java b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageIterable.java similarity index 74% rename from elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageIterator.java rename to elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageIterable.java index 1b16006a48..d9bf8899d6 100644 --- a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageIterator.java +++ b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/MessageIterable.java @@ -6,7 +6,10 @@ package com.yahoo.elide.datastores.jms; +import com.yahoo.elide.core.datastore.DataStoreIterable; + import java.util.Iterator; +import java.util.NoSuchElementException; import java.util.function.Function; import javax.jms.JMSConsumer; import javax.jms.JMSRuntimeException; @@ -16,7 +19,7 @@ * Converts a JMS message consumer into an Iterable. * @param The type to convert a Message into. */ -public class MessageIterator implements Iterable { +public class MessageIterable implements DataStoreIterable { private JMSConsumer consumer; private long timeout; @@ -28,7 +31,7 @@ public class MessageIterator implements Iterable { * @param timeout The timeout to wait on message topics. 0 means no wait. Less than 0 means wait forever. * @param messageConverter Converts JMS messages into some other thing. */ - public MessageIterator( + public MessageIterable( JMSConsumer consumer, long timeout, Function messageConverter @@ -38,6 +41,11 @@ public MessageIterator( this.messageConverter = messageConverter; } + @Override + public Iterable getWrappedIterable() { + return this; + } + @Override public Iterator iterator() { return new Iterator() { @@ -45,8 +53,9 @@ public Iterator iterator() { @Override public boolean hasNext() { - next = next(); - if (next == null) { + try { + next = next(); + } catch (NoSuchElementException e) { return false; } @@ -74,11 +83,26 @@ public T next() { if (message != null) { return messageConverter.apply(message); } - return null; + throw new NoSuchElementException(); } catch (JMSRuntimeException e) { - return null; + throw new NoSuchElementException(); } } }; } + + @Override + public boolean needsInMemoryFilter() { + return true; + } + + @Override + public boolean needsInMemorySort() { + return true; + } + + @Override + public boolean needsInMemoryPagination() { + return true; + } } diff --git a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/SubscriptionRequestScope.java b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/SubscriptionRequestScope.java deleted file mode 100644 index 1a63e38aa6..0000000000 --- a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/SubscriptionRequestScope.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2021, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ - -package com.yahoo.elide.datastores.jms; - -import com.yahoo.elide.ElideSettings; -import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.core.datastore.DataStoreTransaction; -import com.yahoo.elide.core.security.User; -import lombok.Getter; - -import java.util.List; -import java.util.Map; -import java.util.UUID; -import javax.ws.rs.core.MultivaluedHashMap; - -/** - * Request Scope for GraphQL Subscription requests. - */ -public class SubscriptionRequestScope extends RequestScope { - - @Getter - private long timeoutInMs; - - /** - * Constructor. - * @param baseUrlEndpoint base path URL - * @param transaction Data store transaction - * @param user The user - * @param apiVersion The api version - * @param elideSettings Elide settings - * @param requestId Elide internal request ID - * @param requestHeaders HTTP request headers. - * @param timeoutInMs request timeout in milliseconds. 0 means immediate. -1 means no timeout. - */ - public SubscriptionRequestScope( - String baseUrlEndpoint, - DataStoreTransaction transaction, - User user, - String apiVersion, - ElideSettings elideSettings, - UUID requestId, - Map> requestHeaders, - long timeoutInMs - ) { - super(baseUrlEndpoint, "/", apiVersion, null, transaction, user, - new MultivaluedHashMap<>(), requestHeaders, requestId, elideSettings); - - this.timeoutInMs = timeoutInMs; - } -} diff --git a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/TopicType.java b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/TopicType.java deleted file mode 100644 index b093cc396c..0000000000 --- a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/TopicType.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2021, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ - -package com.yahoo.elide.datastores.jms; - -import com.yahoo.elide.core.dictionary.EntityDictionary; -import com.yahoo.elide.core.type.Type; -import lombok.Getter; - -/** - * JMS Topic Names. - */ -@Getter -public enum TopicType { - ADDED("Added"), - DELETED("Deleted"), - UPDATED("Updated"); - - private String topicSuffix; - - /** - * Constructor. - * @param topicSuffix The suffix of the topic name. - */ - TopicType(String topicSuffix) { - this.topicSuffix = topicSuffix; - } - - /** - * Converts a TopicType to a JMS topic name. - * @param type Elide model type. - * @param dictionary Elide entity dictionary. - * @return a JMS topic name. - */ - public String toTopicName(Type type, EntityDictionary dictionary) { - return dictionary.getJsonAliasFor(type) + topicSuffix; - } -} diff --git a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/websocket/SubscriptionWebSocketConfigurator.java b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/websocket/SubscriptionWebSocketConfigurator.java new file mode 100644 index 0000000000..a58c8811eb --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/websocket/SubscriptionWebSocketConfigurator.java @@ -0,0 +1,139 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.jms.websocket; + +import static com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket.DEFAULT_USER_FACTORY; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.audit.AuditLogger; +import com.yahoo.elide.core.audit.Slf4jLogger; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.ErrorMapper; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.datastores.jms.JMSDataStore; +import com.yahoo.elide.graphql.ExecutionResultDeserializer; +import com.yahoo.elide.graphql.ExecutionResultSerializer; +import com.yahoo.elide.graphql.GraphQLErrorDeserializer; +import com.yahoo.elide.graphql.GraphQLErrorSerializer; +import com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket; +import com.fasterxml.jackson.databind.module.SimpleModule; + +import graphql.ExecutionResult; +import graphql.GraphQLError; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; + +import java.util.Calendar; +import javax.jms.ConnectionFactory; +import javax.websocket.server.ServerEndpointConfig; + +/** + * Initializes and configures the subscription web socket. + */ +@Builder +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public class SubscriptionWebSocketConfigurator extends ServerEndpointConfig.Configurator { + + protected ConnectionFactory connectionFactory; + + @Builder.Default + protected AuditLogger auditLogger = new Slf4jLogger(); + + @Builder.Default + protected ErrorMapper errorMapper = error -> null; + + @Builder.Default + protected String baseUrl = "/"; + + @Builder.Default + protected boolean verboseErrors = false; + + @Builder.Default + protected int connectionTimeoutMs = 5000; + + @Builder.Default + protected int maxSubscriptions = 30; + + @Builder.Default + private long maxIdleTimeoutMs = 300000; + + @Builder.Default + private int maxMessageSize = 10000; + + @Builder.Default + protected SubscriptionWebSocket.UserFactory userFactory = DEFAULT_USER_FACTORY; + + @Builder.Default + protected boolean sendPingOnSubscribe = false; + + @Override + public T getEndpointInstance(Class endpointClass) throws InstantiationException { + if (endpointClass.equals(SubscriptionWebSocket.class)) { + + EntityDictionary dictionary = EntityDictionary.builder().build(); + + DataStore store = buildDataStore(dictionary); + + Elide elide = buildElide(store, dictionary); + + return (T) buildWebSocket(elide); + } + + return super.getEndpointInstance(endpointClass); + } + + protected Elide buildElide(DataStore store, EntityDictionary dictionary) { + RSQLFilterDialect rsqlFilterStrategy = RSQLFilterDialect.builder().dictionary(dictionary).build(); + + ElideSettingsBuilder builder = new ElideSettingsBuilder(store) + .withAuditLogger(auditLogger) + .withErrorMapper(errorMapper) + .withBaseUrl(baseUrl) + .withJoinFilterDialect(rsqlFilterStrategy) + .withSubqueryFilterDialect(rsqlFilterStrategy) + .withEntityDictionary(dictionary) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", Calendar.getInstance().getTimeZone()); + + if (verboseErrors) { + builder = builder.withVerboseErrors(); + } + + Elide elide = new Elide(builder.build()); + + elide.doScans(); + + return elide; + } + + protected DataStore buildDataStore(EntityDictionary dictionary) { + return new JMSDataStore( + dictionary.getScanner(), + connectionFactory, dictionary, -1); + } + + protected SubscriptionWebSocket buildWebSocket(Elide elide) { + elide.getMapper().getObjectMapper().registerModule(new SimpleModule("ExecutionResult") + .addDeserializer(GraphQLError.class, new GraphQLErrorDeserializer()) + .addDeserializer(ExecutionResult.class, new ExecutionResultDeserializer()) + .addSerializer(GraphQLError.class, new GraphQLErrorSerializer()) + .addSerializer(ExecutionResult.class, new ExecutionResultSerializer(new GraphQLErrorSerializer()))); + + return SubscriptionWebSocket.builder() + .elide(elide) + .connectTimeoutMs(connectionTimeoutMs) + .maxSubscriptions(maxSubscriptions) + .maxMessageSize(maxMessageSize) + .maxIdleTimeoutMs(maxIdleTimeoutMs) + .userFactory(userFactory) + .sendPingOnSubscribe(sendPingOnSubscribe) + .verboseErrors(verboseErrors) + .build(); + } +} diff --git a/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/websocket/SubscriptionWebSocketTestClient.java b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/websocket/SubscriptionWebSocketTestClient.java new file mode 100644 index 0000000000..b419440e1c --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/main/java/com/yahoo/elide/datastores/jms/websocket/SubscriptionWebSocketTestClient.java @@ -0,0 +1,181 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.jms.websocket; + +import com.yahoo.elide.graphql.ExecutionResultSerializer; +import com.yahoo.elide.graphql.GraphQLErrorSerializer; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Complete; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.ConnectionInit; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Error; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.MessageType; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Next; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Subscribe; +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import graphql.ExecutionResult; +import graphql.GraphQLError; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import javax.websocket.ClientEndpoint; +import javax.websocket.CloseReason; +import javax.websocket.OnClose; +import javax.websocket.OnError; +import javax.websocket.OnMessage; +import javax.websocket.OnOpen; +import javax.websocket.Session; + +/** + * Test client for GraphQL subscriptions. This class makes it simpler to write integration tests. + */ +@ClientEndpoint +@Slf4j +public class SubscriptionWebSocketTestClient { + + private CountDownLatch sessionLatch; + private CountDownLatch subscribeLatch; + private ObjectMapper mapper; + private List results; + private Session session; + private List queries; + private int expectedNumberOfMessages; + private int expectedNumberOfSubscribes; + boolean isOpen = false; + + /** + * Constructor. + * @param expectedNumberOfMessages The number of expected messages before notifying the test driver. + * @param queries The subscription queries to run. + */ + public SubscriptionWebSocketTestClient(int expectedNumberOfMessages, List queries) { + sessionLatch = new CountDownLatch(1); + subscribeLatch = new CountDownLatch(1); + results = new ArrayList<>(); + this.queries = queries; + this.expectedNumberOfMessages = expectedNumberOfMessages; + this.expectedNumberOfSubscribes = queries.size(); + this.mapper = new ObjectMapper(); + GraphQLErrorSerializer errorSerializer = new GraphQLErrorSerializer(); + SimpleModule module = new SimpleModule("ExecutionResultSerializer", Version.unknownVersion()); + module.addSerializer(ExecutionResult.class, new ExecutionResultSerializer(errorSerializer)); + module.addSerializer(GraphQLError.class, errorSerializer); + mapper.registerModule(module); + } + + + @OnOpen + public void onOpen(Session session) throws Exception { + this.session = session; + log.debug("WebSocket opened: " + session.getId()); + + isOpen = true; + + session.getBasicRemote().sendText(mapper.writeValueAsString(new ConnectionInit())); + } + + @OnMessage + public void onMessage(String text) throws Exception { + + JsonNode type = mapper.readTree(text).get("type"); + MessageType messageType = MessageType.valueOf(type.textValue().toUpperCase(Locale.ROOT)); + + switch (messageType) { + case CONNECTION_ACK: { + Integer id = 1; + for (String query : queries) { + Subscribe subscribe = Subscribe.builder() + .id(id.toString()) + .payload(Subscribe.Payload.builder() + .query(query) + .build()) + .build(); + + session.getBasicRemote().sendText(mapper.writeValueAsString(subscribe)); + id++; + } + + break; + } + case NEXT: { + Next next = mapper.readValue(text, Next.class); + results.add(next.getPayload()); + expectedNumberOfMessages--; + if (expectedNumberOfMessages <= 0) { + sessionLatch.countDown(); + } + break; + } + case PING: { + expectedNumberOfSubscribes--; + if (expectedNumberOfSubscribes <= 0) { + subscribeLatch.countDown(); + } + break; + } + case ERROR: { + Error error = mapper.readValue(text, Error.class); + log.error("ERROR: {}", error.getPayload()); + sessionLatch.countDown(); + break; + } + default: { + break; + } + } + } + + @OnClose + public void onClose(CloseReason reason) throws Exception { + log.debug("Session closed: " + reason.getCloseCode() + " " + reason.getReasonPhrase()); + isOpen = false; + sessionLatch.countDown(); + } + + @OnError + public void onError(Throwable t) throws Exception { + log.error("Session error: " + t.getMessage()); + isOpen = false; + sessionLatch.countDown(); + } + + public void sendClose() throws Exception { + if (isOpen) { + Integer id = 1; + for (String query : queries) { + session.getBasicRemote().sendText(mapper.writeValueAsString(new Complete(id.toString()))); + id++; + } + isOpen = false; + } + } + + /** + * Wait for the subscription to deliver N messages and return them. + * @param waitInSeconds The number of seconds to wait before giving up. + * @return The messages received. + * @throws InterruptedException + */ + public List waitOnClose(int waitInSeconds) throws InterruptedException { + sessionLatch.await(waitInSeconds, TimeUnit.SECONDS); + return results; + } + + /** + * Wait for the subscription to be setup. + * @param waitInSeconds The number of seconds to wait before giving up. + * @throws InterruptedException + */ + public void waitOnSubscribe(int waitInSeconds) throws InterruptedException { + subscribeLatch.await(waitInSeconds, TimeUnit.SECONDS); + } +} diff --git a/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/JMSDataStoreIntegrationTest.java b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/JMSDataStoreIntegrationTest.java new file mode 100644 index 0000000000..fb77ee67e9 --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/JMSDataStoreIntegrationTest.java @@ -0,0 +1,478 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.jms; + +import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.datastores.jms.TestBinder.EMBEDDED_JMS_URL; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.linkage; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relationships; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketConfigurator; +import com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketTestClient; +import com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket; +import com.yahoo.elide.jsonapi.resources.JsonApiEndpoint; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; +import org.apache.activemq.artemis.core.server.JournalType; +import org.apache.activemq.artemis.core.server.embedded.EmbeddedActiveMQ; +import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpStatus; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.websocket.javax.server.config.JavaxWebSocketServletContainerInitializer; +import org.glassfish.jersey.servlet.ServletContainer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import graphql.ExecutionResult; +import io.restassured.RestAssured; +import lombok.extern.slf4j.Slf4j; + +import java.net.URI; +import java.util.List; +import javax.websocket.ContainerProvider; +import javax.websocket.Session; +import javax.websocket.WebSocketContainer; +import javax.websocket.server.ServerEndpointConfig; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +public class JMSDataStoreIntegrationTest { + private EmbeddedActiveMQ embedded; + + @BeforeAll + public void init() throws Exception { + + //Startup up an embedded active MQ. + embedded = new EmbeddedActiveMQ(); + Configuration configuration = new ConfigurationImpl(); + configuration.addAcceptorConfiguration("default", EMBEDDED_JMS_URL); + configuration.setPersistenceEnabled(false); + configuration.setSecurityEnabled(false); + configuration.setJournalType(JournalType.NIO); + + embedded.setConfiguration(configuration); + embedded.start(); + + //Start embedded Jetty + setUpServer(); + } + + @AfterAll + public void shutdown() throws Exception { + embedded.stop(); + } + + protected final Server setUpServer() throws Exception { + ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); + + // setup RestAssured + RestAssured.baseURI = "http://localhost/"; + RestAssured.basePath = "/"; + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + + // port randomly picked in pom.xml + RestAssured.port = getRestAssuredPort(); + + // embedded jetty server + Server server = new Server(RestAssured.port); + servletContextHandler.setContextPath("/"); + server.setHandler(servletContextHandler); + + //JSON API + final ServletHolder servletHolder = servletContextHandler.addServlet(ServletContainer.class, "/*"); + servletHolder.setInitOrder(1); + servletHolder.setInitParameter("jersey.config.server.provider.packages", + JsonApiEndpoint.class.getPackage().getName()); + servletHolder.setInitParameter("javax.ws.rs.Application", TestResourceConfig.class.getName()); + + //GraphQL API + ServletHolder graphqlServlet = servletContextHandler.addServlet(ServletContainer.class, "/graphQL/*"); + graphqlServlet.setInitOrder(2); + graphqlServlet.setInitParameter("jersey.config.server.provider.packages", + com.yahoo.elide.graphql.GraphQLEndpoint.class.getPackage().getName()); + graphqlServlet.setInitParameter("javax.ws.rs.Application", TestResourceConfig.class.getName()); + + // GraphQL subscription endpoint + JavaxWebSocketServletContainerInitializer.configure(servletContextHandler, (servletContext, serverContainer) -> + { + serverContainer.addEndpoint(ServerEndpointConfig.Builder + .create(SubscriptionWebSocket.class, "/subscription") + .configurator(SubscriptionWebSocketConfigurator.builder() + .baseUrl("/subscription") + .connectionFactory(new ActiveMQConnectionFactory(EMBEDDED_JMS_URL)) + .sendPingOnSubscribe(true) + .build()) + .build()); + }); + + log.debug("...Starting Server..."); + server.start(); + + return server; + } + + @Test + public void testLifecycleEventBeforeSubscribe() throws Exception { + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("book"), + id("2"), + attributes(attr("title", "foo")) + ) + ) + ) + .post("/book") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("2")); + + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + SubscriptionWebSocketTestClient client = new SubscriptionWebSocketTestClient(1, + List.of("subscription {book(topic: ADDED) {id title}}")); + try (Session session = container.connectToServer(client, new URI("ws://localhost:9999/subscription"))) { + + //Wait for the socket to be full established. + client.waitOnSubscribe(10); + + List results = client.waitOnClose(1); + + assertEquals(0, results.size()); + } + } + + @Test + public void testLifecycleEventAfterSubscribe() throws Exception { + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + SubscriptionWebSocketTestClient client = new SubscriptionWebSocketTestClient(1, + List.of("subscription {book(topic: ADDED) {id title}}")); + + try (Session session = container.connectToServer(client, new URI("ws://localhost:9999/subscription"))) { + + //Wait for the socket to be full established. + client.waitOnSubscribe(10); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("book"), + id("1"), + attributes(attr("title", "foo")) + ) + ) + ) + .post("/book") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("1")); + + + List results = client.waitOnClose(10); + assertEquals(1, results.size()); + } + } + + @Test + public void testLifecycleEventAfterSubscribeWithInaccessibleField() throws Exception { + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + SubscriptionWebSocketTestClient client = new SubscriptionWebSocketTestClient(1, + List.of("subscription {book(topic: ADDED) {id title nope}}")); + + try (Session session = container.connectToServer(client, new URI("ws://localhost:9999/subscription"))) { + + //Wait for the socket to be full established. + client.waitOnSubscribe(10); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("book"), + id("14"), + attributes(attr("title", "foo")) + ) + ) + ) + .post("/book") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("14")); + + + List results = client.waitOnClose(10); + assertEquals(1, results.size()); + assertEquals("{book={id=14, title=foo, nope=null}}", results.get(0).getData().toString()); + assertEquals("[{ \"message\": \"Exception while fetching data (/book/nope) : ReadPermission Denied\", \"locations\": [SourceLocation{line=1, column=44}], \"path\": [book, nope]}]", results.get(0).getErrors().toString()); + } + } + + @Test + public void testLifecycleEventAfterSubscribeWithFilter() throws Exception { + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + SubscriptionWebSocketTestClient client = new SubscriptionWebSocketTestClient(1, + List.of("subscription {book(topic: ADDED, filter: \"title==foo\") {id title}}")); + + try (Session session = container.connectToServer(client, new URI("ws://localhost:9999/subscription"))) { + + //Wait for the socket to be full established. + client.waitOnSubscribe(10); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("book"), + id("10"), + attributes(attr("title", "bar")) + ) + ) + ) + .post("/book") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("10")); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("book"), + id("11"), + attributes(attr("title", "foo")) + ) + ) + ) + .post("/book") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("11")); + + + List results = client.waitOnClose(10); + assertEquals(1, results.size()); + } + } + + @Test + public void testLifecycleEventAfterSubscribeWithSecurityFilter() throws Exception { + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + SubscriptionWebSocketTestClient client = new SubscriptionWebSocketTestClient(1, + List.of("subscription {book(topic: ADDED) {id title}}")); + + try (Session session = container.connectToServer(client, new URI("ws://localhost:9999/subscription"))) { + + //Wait for the socket to be full established. + client.waitOnSubscribe(10); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("book"), + id("1000"), + attributes(attr("title", "bar")) + ) + ) + ) + .post("/book") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("1000")); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("book"), + id("99"), + attributes(attr("title", "foo")) + ) + ) + ) + .post("/book") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("99")); + + + List results = client.waitOnClose(10); + assertEquals(1, results.size()); + } + } + + @Test + public void testCreateUpdateAndDelete() throws Exception { + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + SubscriptionWebSocketTestClient client = new SubscriptionWebSocketTestClient(4, + List.of( + "subscription {book(topic: ADDED) { id title authors { id name } publisher { id name }}}", + "subscription {book(topic: DELETED) { id title }}", + "subscription {book(topic: UPDATED) { id title publisher { id name }}}" + )); + + try (Session session = container.connectToServer(client, new URI("ws://localhost:9999/subscription"))) { + + //Wait for the socket to be full established. + client.waitOnSubscribe(10); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("publisher"), + id("1"), + attributes(attr("name", "Some Company")) + ) + ) + ) + .post("/publisher") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("1")); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("author"), + id("1"), + attributes(attr("name", "Jane Doe")) + ) + ) + ) + .post("/author") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("1")); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("book"), + id("3"), + attributes(attr("title", "foo")), + relationships( + relation("authors", linkage(type("author"), id("1"))), + relation("publisher", linkage(type("publisher"), id("1"))) + ) + ) + ) + ) + .post("/book") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("3")); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("book"), + id("3"), + attributes(attr("title", "new title")) + ) + ) + ) + .patch("/book/3") + .then().log().all().statusCode(HttpStatus.SC_NO_CONTENT); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("publisher"), + id("1") + ) + ) + ) + .delete("/book/3/relationships/publisher") + .then().log().all().statusCode(HttpStatus.SC_NO_CONTENT); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .delete("/book/3") + .then().statusCode(HttpStatus.SC_NO_CONTENT); + + List results = client.waitOnClose(300); + + assertEquals(4, results.size()); + assertEquals("{book={id=3, title=foo, authors=[{id=1, name=Jane Doe}], publisher={id=1, name=Some Company}}}", results.get(0).getData().toString()); + assertEquals("{book={id=3, title=new title, publisher={id=1, name=Some Company}}}", results.get(1).getData().toString()); + assertEquals("{book={id=3, title=new title, publisher=null}}", results.get(2).getData().toString()); + assertEquals("{book={id=3, title=new title}}", results.get(3).getData().toString()); + } + } + + @Test + public void testCustomSubscription() throws Exception { + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + SubscriptionWebSocketTestClient client = new SubscriptionWebSocketTestClient(3, + List.of("subscription {chat {id message}}")); + + try (Session session = container.connectToServer(client, new URI("ws://localhost:9999/subscription"))) { + + //Wait for the socket to be full established. + client.waitOnSubscribe(10); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + data( + resource( + type("chatBot"), + id("1"), + attributes(attr("name", "SocialBot")) + ) + ) + ) + .post("/chatBot") + .then().statusCode(HttpStatus.SC_CREATED).body("data.id", equalTo("1")); + + List results = client.waitOnClose(10); + assertEquals(3, results.size()); + assertEquals("{chat={id=1, message=Hello!}}", results.get(0).getData().toString()); + assertEquals("{chat={id=2, message=How is your day?}}", results.get(1).getData().toString()); + assertEquals("{chat={id=3, message=My name is SocialBot}}", results.get(2).getData().toString()); + } + } + + public static Integer getRestAssuredPort() { + String restassuredPort = System.getProperty("restassured.port", System.getenv("restassured.port")); + return Integer.parseInt(StringUtils.isNotEmpty(restassuredPort) ? restassuredPort : "9999"); + } +} diff --git a/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/JMSDataStoreTest.java b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/JMSDataStoreTest.java index 5535f6a141..1357bf4fe2 100644 --- a/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/JMSDataStoreTest.java +++ b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/JMSDataStoreTest.java @@ -6,6 +6,8 @@ package com.yahoo.elide.datastores.jms; +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static com.yahoo.elide.datastores.jms.TestBinder.EMBEDDED_JMS_URL; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -17,30 +19,44 @@ import com.yahoo.elide.core.request.EntityProjection; import com.yahoo.elide.core.request.Relationship; import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.graphql.subscriptions.hooks.TopicType; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.Sets; import example.Author; import example.Book; +import example.Chat; import org.apache.activemq.artemis.core.config.Configuration; import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; import org.apache.activemq.artemis.core.server.JournalType; import org.apache.activemq.artemis.core.server.embedded.EmbeddedActiveMQ; import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import java.util.Collections; import java.util.Iterator; import java.util.Set; +import java.util.UUID; +import javax.jms.ConnectionFactory; import javax.jms.Destination; import javax.jms.JMSContext; import javax.jms.JMSProducer; +@TestInstance(TestInstance.Lifecycle.PER_CLASS) public class JMSDataStoreTest { - @Test - public void testLoadObjects() throws Exception { - EmbeddedActiveMQ embedded = new EmbeddedActiveMQ(); + protected ConnectionFactory connectionFactory; + protected EntityDictionary dictionary; + protected JMSDataStore store; + protected EmbeddedActiveMQ embedded; + + @BeforeAll + public void init() throws Exception { + embedded = new EmbeddedActiveMQ(); Configuration configuration = new ConfigurationImpl(); - configuration.addAcceptorConfiguration("default", "vm://0"); + configuration.addAcceptorConfiguration("default", EMBEDDED_JMS_URL); configuration.setPersistenceEnabled(false); configuration.setSecurityEnabled(false); configuration.setJournalType(JournalType.NIO); @@ -48,10 +64,29 @@ public void testLoadObjects() throws Exception { embedded.setConfiguration(configuration); embedded.start(); - ActiveMQConnectionFactory cf = new ActiveMQConnectionFactory("vm://0"); + connectionFactory = new ActiveMQConnectionFactory(EMBEDDED_JMS_URL); + dictionary = EntityDictionary.builder().build(); + + store = new JMSDataStore(Sets.newHashSet(ClassType.of(Book.class), ClassType.of(Author.class), + ClassType.of(Chat.class)), + connectionFactory, dictionary, 2500); + store.populateEntityDictionary(dictionary); + } + + @AfterAll + public void shutdown() throws Exception { + embedded.stop(); + } - EntityDictionary dictionary = EntityDictionary.builder().build(); + @Test + public void testModelLabels() throws Exception { + assertTrue(store.models.get(ClassType.of(Book.class))); + assertTrue(store.models.get(ClassType.of(Author.class))); + assertFalse(store.models.get(ClassType.of(Chat.class))); + } + @Test + public void testLoadObjects() throws Exception { Author author1 = new Author(); author1.setId(1); author1.setName("Jon Doe"); @@ -65,23 +100,22 @@ public void testLoadObjects() throws Exception { book2.setTitle("Grapes of Wrath"); book2.setId(2); - JMSDataStore store = new JMSDataStore(Sets.newHashSet( - ClassType.of(Book.class), ClassType.of(Author.class)), cf, dictionary); - store.populateEntityDictionary(dictionary); - try (DataStoreTransaction tx = store.beginReadTransaction()) { - RequestScope scope = new SubscriptionRequestScope( + + RequestScope scope = new RequestScope( "/json", + "/", + NO_VERSION, + null, tx, null, - "1.0", + null, + Collections.EMPTY_MAP, + UUID.randomUUID(), new ElideSettingsBuilder(store) .withEntityDictionary(dictionary) - .build(), - null, - null, - 2000); + .build()); Iterable books = tx.loadObjects( EntityProjection.builder() @@ -93,7 +127,7 @@ public void testLoadObjects() throws Exception { scope ); - JMSContext context = cf.createContext(); + JMSContext context = connectionFactory.createContext(); Destination destination = context.createTopic("bookAdded"); JMSProducer producer = context.createProducer(); @@ -108,13 +142,13 @@ public void testLoadObjects() throws Exception { assertEquals("Enders Game", receivedBook.getTitle()); assertEquals(1, receivedBook.getId()); - Set receivedAuthors = tx.getRelation(tx, receivedBook, + Set receivedAuthors = Sets.newHashSet((Iterable) tx.getToManyRelation(tx, receivedBook, Relationship.builder() .name("authors") .projection(EntityProjection.builder() .type(Author.class) .build()) - .build(), scope); + .build(), scope)); assertTrue(receivedAuthors.contains(author1)); diff --git a/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageDeserializerTest.java b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageDeserializerTest.java index a74a38de44..5423824ea7 100644 --- a/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageDeserializerTest.java +++ b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageDeserializerTest.java @@ -9,6 +9,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.yahoo.elide.core.type.ClassType; +import com.google.gson.GsonBuilder; import example.Book; import org.junit.jupiter.api.Test; @@ -21,7 +22,8 @@ public void testDeserialization() throws Exception { TextMessage message = mock(TextMessage.class); when(message.getText()).thenReturn("{ \"title\": \"Foo\", \"id\" : 123 }"); - MessageDeserializer deserializer = new MessageDeserializer(ClassType.of(Book.class)); + MessageDeserializer deserializer = new MessageDeserializer(ClassType.of(Book.class), + new GsonBuilder().create()); Book book = deserializer.apply(message); assertEquals("Foo", book.getTitle()); diff --git a/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageIteratorTest.java b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageIterableTest.java similarity index 88% rename from elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageIteratorTest.java rename to elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageIterableTest.java index 1955ca443e..c8f384e440 100644 --- a/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageIteratorTest.java +++ b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/MessageIterableTest.java @@ -12,6 +12,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.yahoo.elide.core.type.ClassType; +import com.google.gson.GsonBuilder; import org.junit.jupiter.api.Test; import java.util.Iterator; @@ -19,7 +20,7 @@ import javax.jms.JMSRuntimeException; import javax.jms.TextMessage; -public class MessageIteratorTest { +public class MessageIterableTest { @Test public void testIteratorMultipleItems() throws Exception { @@ -37,10 +38,10 @@ public void testIteratorMultipleItems() throws Exception { .thenReturn(msg3) .thenThrow(new JMSRuntimeException("timeout")); - Iterator iterator = new MessageIterator( + Iterator iterator = new MessageIterable( consumer, 1000, - new MessageDeserializer(ClassType.of(String.class))).iterator(); + new MessageDeserializer(ClassType.of(String.class), new GsonBuilder().create())).iterator(); assertTrue(iterator.hasNext()); assertEquals("1", iterator.next()); @@ -61,10 +62,10 @@ public void testZeroTimeout() throws Exception { .thenReturn(msg1) .thenReturn(null); - Iterator iterator = new MessageIterator( + Iterator iterator = new MessageIterable( consumer, 0, - new MessageDeserializer(ClassType.of(String.class))).iterator(); + new MessageDeserializer(ClassType.of(String.class), new GsonBuilder().create())).iterator(); assertTrue(iterator.hasNext()); assertEquals("1", iterator.next()); @@ -81,10 +82,10 @@ public void testNegativeTimeout() throws Exception { .thenReturn(msg1) .thenReturn(null); - Iterator iterator = new MessageIterator( + Iterator iterator = new MessageIterable( consumer, -1, - new MessageDeserializer(ClassType.of(String.class))).iterator(); + new MessageDeserializer(ClassType.of(String.class), new GsonBuilder().create())).iterator(); assertTrue(iterator.hasNext()); assertEquals("1", iterator.next()); diff --git a/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/TestBinder.java b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/TestBinder.java new file mode 100644 index 0000000000..4fd13ac962 --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/TestBinder.java @@ -0,0 +1,99 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.jms; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.audit.AuditLogger; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.graphql.subscriptions.hooks.SubscriptionScanner; +import example.Author; +import example.Book; +import example.ChatBot; +import example.Publisher; +import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; +import org.glassfish.hk2.api.Factory; +import org.glassfish.hk2.api.ServiceLocator; +import org.glassfish.hk2.utilities.binding.AbstractBinder; + +import java.util.Calendar; +import java.util.Set; +import javax.jms.ConnectionFactory; + +/** + * HK2 Binder for the Integration test. + */ +public class TestBinder extends AbstractBinder { + + public static final String EMBEDDED_JMS_URL = "vm://0"; + + private final AuditLogger auditLogger; + private final ServiceLocator injector; + + public TestBinder(final AuditLogger auditLogger, final ServiceLocator injector) { + this.auditLogger = auditLogger; + this.injector = injector; + } + + @Override + protected void configure() { + EntityDictionary dictionary = EntityDictionary.builder() + .injector(injector::inject) + .build(); + + dictionary.scanForSecurityChecks(); + + bind(dictionary).to(EntityDictionary.class); + + ConnectionFactory connectionFactory = new ActiveMQConnectionFactory(EMBEDDED_JMS_URL); + + bind(connectionFactory).to(ConnectionFactory.class); + + // Primary Elide instance for CRUD endpoints. + bindFactory(new Factory() { + @Override + public Elide provide() { + + HashMapDataStore inMemoryStore = new HashMapDataStore( + Set.of(Book.class, Author.class, Publisher.class, ChatBot.class) + ); + Elide elide = buildElide(inMemoryStore, dictionary); + + elide.doScans(); + + SubscriptionScanner subscriptionScanner = SubscriptionScanner.builder() + .connectionFactory(connectionFactory) + .dictionary(elide.getElideSettings().getDictionary()) + .scanner(elide.getScanner()) + .build(); + + subscriptionScanner.bindLifecycleHooks(); + return elide; + } + + @Override + public void dispose(Elide elide) { + + } + }).to(Elide.class).named("elide"); + } + + protected Elide buildElide(DataStore store, EntityDictionary dictionary) { + RSQLFilterDialect rsqlFilterStrategy = RSQLFilterDialect.builder().dictionary(dictionary).build(); + + return new Elide(new ElideSettingsBuilder(store) + .withAuditLogger(auditLogger) + .withJoinFilterDialect(rsqlFilterStrategy) + .withSubqueryFilterDialect(rsqlFilterStrategy) + .withEntityDictionary(dictionary) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", Calendar.getInstance().getTimeZone()) + .build()); + } +} diff --git a/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/TestResourceConfig.java b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/TestResourceConfig.java new file mode 100644 index 0000000000..42d36ac2e1 --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/test/java/com/yahoo/elide/datastores/jms/TestResourceConfig.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.jms; + +import com.yahoo.elide.core.audit.Slf4jLogger; +import org.glassfish.hk2.api.ServiceLocator; +import org.glassfish.jersey.server.ResourceConfig; + +import javax.inject.Inject; + +/** + * Resource configuration for integration tests. + */ +public class TestResourceConfig extends ResourceConfig { + + @Inject + public TestResourceConfig(ServiceLocator injector) { + register(new TestBinder(new Slf4jLogger(), injector)); + } +} diff --git a/elide-datastore/elide-datastore-jms/src/test/java/example/Author.java b/elide-datastore/elide-datastore-jms/src/test/java/example/Author.java index 203c51d28d..025184b82f 100644 --- a/elide-datastore/elide-datastore-jms/src/test/java/example/Author.java +++ b/elide-datastore/elide-datastore-jms/src/test/java/example/Author.java @@ -7,15 +7,19 @@ package example; import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; import lombok.Data; import javax.persistence.Id; @Include @Data +@Subscription(operations = Subscription.Operation.CREATE) public class Author { @Id private long id; + @SubscriptionField private String name; } diff --git a/elide-datastore/elide-datastore-jms/src/test/java/example/Book.java b/elide-datastore/elide-datastore-jms/src/test/java/example/Book.java index 95f9ce628a..6bf7401b68 100644 --- a/elide-datastore/elide-datastore-jms/src/test/java/example/Book.java +++ b/elide-datastore/elide-datastore-jms/src/test/java/example/Book.java @@ -6,21 +6,39 @@ package example; +import static example.checks.InternalBookCheck.HIDDEN_BOOK; import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; import lombok.Data; import java.util.Set; import javax.persistence.Id; import javax.persistence.ManyToMany; +import javax.persistence.ManyToOne; @Include @Data +@Subscription +@ReadPermission(expression = HIDDEN_BOOK) public class Book { @Id private long id; + @SubscriptionField private String title; + @SubscriptionField @ManyToMany private Set authors; + + @SubscriptionField + @ManyToOne + private Publisher publisher; + + //Nobody can read this. + @ReadPermission(expression = "NONE") + @SubscriptionField + private String nope; } diff --git a/elide-datastore/elide-datastore-jms/src/test/java/example/Chat.java b/elide-datastore/elide-datastore-jms/src/test/java/example/Chat.java new file mode 100644 index 0000000000..1205d00415 --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/test/java/example/Chat.java @@ -0,0 +1,35 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example; + +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Id; + +@Include(name = Chat.CHAT) + +//This is a custom subscription +@Subscription(operations = {}) +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Chat { + + public static final String CHAT = "chat"; + + @Id + long id; + + @SubscriptionField + String message; +} diff --git a/elide-datastore/elide-datastore-jms/src/test/java/example/ChatBot.java b/elide-datastore/elide-datastore-jms/src/test/java/example/ChatBot.java new file mode 100644 index 0000000000..243c1ddb4a --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/test/java/example/ChatBot.java @@ -0,0 +1,29 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example; + +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import example.hooks.ChatBotCreateHook; +import lombok.Data; + +import javax.persistence.Id; + +@Include +@Data +@LifeCycleHookBinding( + hook = ChatBotCreateHook.class, + operation = LifeCycleHookBinding.Operation.CREATE, + phase = LifeCycleHookBinding.TransactionPhase.POSTCOMMIT +) +public class ChatBot { + + @Id + long id; + + String name; +} diff --git a/elide-datastore/elide-datastore-jms/src/test/java/example/Publisher.java b/elide-datastore/elide-datastore-jms/src/test/java/example/Publisher.java new file mode 100644 index 0000000000..5adcd72d6c --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/test/java/example/Publisher.java @@ -0,0 +1,23 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example; + +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; +import lombok.Data; + +import javax.persistence.Id; + +@Include +@Data +public class Publisher { + @Id + private long id; + + @SubscriptionField + private String name; +} diff --git a/elide-datastore/elide-datastore-jms/src/test/java/example/checks/InternalBookCheck.java b/elide-datastore/elide-datastore-jms/src/test/java/example/checks/InternalBookCheck.java new file mode 100644 index 0000000000..9fd352ebe4 --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/test/java/example/checks/InternalBookCheck.java @@ -0,0 +1,28 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.checks; + +import static example.checks.InternalBookCheck.HIDDEN_BOOK; +import com.yahoo.elide.annotation.SecurityCheck; +import com.yahoo.elide.core.Path; +import com.yahoo.elide.core.filter.expression.FilterExpression; +import com.yahoo.elide.core.filter.predicates.LEPredicate; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.security.checks.FilterExpressionCheck; +import com.yahoo.elide.core.type.Type; +import example.Book; + +@SecurityCheck(HIDDEN_BOOK) +public class InternalBookCheck extends FilterExpressionCheck { + + public static final String HIDDEN_BOOK = "hidden book"; + + @Override + public FilterExpression getFilterExpression(Type entityClass, RequestScope requestScope) { + return new LEPredicate(new Path.PathElement(Book.class, Long.class, "id"), 100); + } +} diff --git a/elide-datastore/elide-datastore-jms/src/test/java/example/hooks/ChatBotCreateHook.java b/elide-datastore/elide-datastore-jms/src/test/java/example/hooks/ChatBotCreateHook.java new file mode 100644 index 0000000000..e23639e27a --- /dev/null +++ b/elide-datastore/elide-datastore-jms/src/test/java/example/hooks/ChatBotCreateHook.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.hooks; + +import static example.Chat.CHAT; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.lifecycle.LifeCycleHook; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.graphql.subscriptions.hooks.NotifyTopicLifeCycleHook; +import com.google.gson.GsonBuilder; +import example.Chat; +import example.ChatBot; + +import lombok.Data; + +import java.util.Optional; +import javax.inject.Inject; +import javax.jms.ConnectionFactory; +import javax.jms.JMSContext; + +@Data +public class ChatBotCreateHook implements LifeCycleHook { + + @Inject + ConnectionFactory connectionFactory; + + @Override + public void execute( + LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + ChatBot bot, + RequestScope requestScope, + Optional changes) { + + NotifyTopicLifeCycleHook publisher = new NotifyTopicLifeCycleHook<>( + connectionFactory, + JMSContext::createProducer, + new GsonBuilder().create() + ); + + publisher.publish(new Chat(1, "Hello!"), CHAT); + publisher.publish(new Chat(2, "How is your day?"), CHAT); + publisher.publish(new Chat(3, "My name is " + bot.getName()), CHAT); + } +} diff --git a/elide-datastore/elide-datastore-jpa/pom.xml b/elide-datastore/elide-datastore-jpa/pom.xml index 25d43965cf..8d60c319cb 100644 --- a/elide-datastore/elide-datastore-jpa/pom.xml +++ b/elide-datastore/elide-datastore-jpa/pom.xml @@ -14,7 +14,7 @@ com.yahoo.elide elide-datastore-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -52,7 +52,7 @@ com.yahoo.elide elide-datastore-hibernate - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -74,7 +74,7 @@ com.yahoo.elide elide-integration-tests - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT test-jar test diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/JpaDataStore.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/JpaDataStore.java index 8ea8e510b0..3cb6b5b06d 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/JpaDataStore.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/JpaDataStore.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/porting/EntityManagerWrapper.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/porting/EntityManagerWrapper.java index fd080cc877..c5c794d80f 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/porting/EntityManagerWrapper.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/porting/EntityManagerWrapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/porting/QueryWrapper.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/porting/QueryWrapper.java index d24a57f00d..b1a9089389 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/porting/QueryWrapper.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/porting/QueryWrapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/AbstractJpaTransaction.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/AbstractJpaTransaction.java index 562f333c54..e188ba2e0d 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/AbstractJpaTransaction.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/AbstractJpaTransaction.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/JpaTransaction.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/JpaTransaction.java index e200bf9390..53d2088744 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/JpaTransaction.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/JpaTransaction.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/JtaTransaction.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/JtaTransaction.java index ef9ac67f61..97603635a4 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/JtaTransaction.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/JtaTransaction.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/NonJtaTransaction.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/NonJtaTransaction.java index e447e0cf44..b59a85f36c 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/NonJtaTransaction.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/NonJtaTransaction.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -28,7 +28,7 @@ public class NonJtaTransaction extends AbstractJpaTransaction { * @param jpaTransactionCancel A function which can cancel a session. */ public NonJtaTransaction(EntityManager entityManager, Consumer jpaTransactionCancel) { - this(entityManager, jpaTransactionCancel, DEFAULT_LOGGER, false); + this(entityManager, jpaTransactionCancel, DEFAULT_LOGGER, false, true); } /** @@ -39,11 +39,13 @@ public NonJtaTransaction(EntityManager entityManager, Consumer jp * @param delegateToInMemoryStore When fetching a subcollection from another multi-element collection, * whether or not to do sorting, filtering and pagination in memory - or * do N+1 queries. + * @param isScrollEnabled Enables/disables scrollable iterators. */ public NonJtaTransaction(EntityManager entityManager, Consumer jpaTransactionCancel, QueryLogger logger, - boolean delegateToInMemoryStore) { - super(entityManager, jpaTransactionCancel, logger, delegateToInMemoryStore); + boolean delegateToInMemoryStore, + boolean isScrollEnabled) { + super(entityManager, jpaTransactionCancel, logger, delegateToInMemoryStore, isScrollEnabled); this.transaction = entityManager.getTransaction(); entityManager.clear(); } diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/PersistentCollectionChecker.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/PersistentCollectionChecker.java index 12b433c297..75102cb173 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/PersistentCollectionChecker.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/PersistentCollectionChecker.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/classes/EclipseLinkPersistentCollections.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/classes/EclipseLinkPersistentCollections.java index df07f74fde..ff295c3d97 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/classes/EclipseLinkPersistentCollections.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/classes/EclipseLinkPersistentCollections.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/classes/HibernatePersistentCollections.java b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/classes/HibernatePersistentCollections.java index 75b631db6a..0301af9cb3 100644 --- a/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/classes/HibernatePersistentCollections.java +++ b/elide-datastore/elide-datastore-jpa/src/main/java/com/yahoo/elide/datastores/jpa/transaction/checker/classes/HibernatePersistentCollections.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreHarness.java b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreHarness.java index a72b435647..ef45d128cb 100644 --- a/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreHarness.java +++ b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreHarness.java @@ -19,6 +19,7 @@ import example.Company; import example.Parent; import example.models.generics.Manager; +import example.models.targetEntity.SWE; import example.models.triggers.Invoice; import example.models.versioned.BookV2; @@ -48,7 +49,7 @@ */ public class JpaDataStoreHarness implements DataStoreTestHarness { - private static final String JDBC = "jdbc:h2:mem:root;IGNORECASE=TRUE"; + private static final String JDBC = "jdbc:h2:mem:root;IGNORECASE=TRUE;MODE=MYSQL;NON_KEYWORDS=VALUE,USER"; private static final String ROOT = "root"; private DataStore store; @@ -67,6 +68,7 @@ public JpaDataStoreHarness(QueryLogger logger, boolean delegateToInMemoryStore) try { bindClasses.addAll(scanner.getAnnotatedClasses(Parent.class.getPackage(), Entity.class)); bindClasses.addAll(scanner.getAnnotatedClasses(Manager.class.getPackage(), Entity.class)); + bindClasses.addAll(scanner.getAnnotatedClasses(SWE.class.getPackage(), Entity.class)); bindClasses.addAll(scanner.getAnnotatedClasses(Invoice.class.getPackage(), Entity.class)); bindClasses.addAll(scanner.getAnnotatedClasses(BookV2.class.getPackage(), Entity.class)); bindClasses.addAll(scanner.getAnnotatedClasses(AsyncQuery.class.getPackage(), Entity.class)); @@ -79,7 +81,7 @@ public JpaDataStoreHarness(QueryLogger logger, boolean delegateToInMemoryStore) options.put("javax.persistence.jdbc.url", JDBC); options.put("javax.persistence.jdbc.user", ROOT); options.put("javax.persistence.jdbc.password", ROOT); - options.put("hibernate.dialect", "org.hibernate.dialect.H2Dialect"); + options.put("hibernate.dialect", "org.hibernate.dialect.MySQLDialect"); options.put(AvailableSettings.LOADED_CLASSES, bindClasses); EntityManagerFactory emf = Persistence.createEntityManagerFactory("elide-tests", options); @@ -116,7 +118,7 @@ public JpaDataStoreHarness(QueryLogger logger, boolean delegateToInMemoryStore) store = new JpaDataStore( () -> emf.createEntityManager(), - entityManager -> new NonJtaTransaction(entityManager, txCancel, logger, delegateToInMemoryStore) + entityManager -> new NonJtaTransaction(entityManager, txCancel, logger, delegateToInMemoryStore, true) ); } diff --git a/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreTransactionTest.java b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreTransactionTest.java index d975ff8029..82dd29523c 100644 --- a/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreTransactionTest.java +++ b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/JpaDataStoreTransactionTest.java @@ -5,34 +5,69 @@ */ package com.yahoo.elide.datastores.jpa; +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; import static com.yahoo.elide.datastores.jpa.JpaDataStore.DEFAULT_LOGGER; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import com.yahoo.elide.ElideSettings; -import com.yahoo.elide.ElideSettingsBuilder; +import static org.mockito.Mockito.when; import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.core.datastore.DataStore; -import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.filter.expression.FilterExpression; import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.datastores.jpa.transaction.AbstractJpaTransaction; import example.Author; import example.Book; -import org.junit.jupiter.api.Test; +import org.hibernate.collection.internal.PersistentSet; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -import java.util.Optional; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Stream; import javax.persistence.EntityManager; +import javax.persistence.Query; +@TestInstance(TestInstance.Lifecycle.PER_CLASS) public class JpaDataStoreTransactionTest { + protected EntityDictionary dictionary; + protected RequestScope scope; + protected EntityManager entityManager; + protected Query query; + + @BeforeAll + public void initCommonMocks() { + dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); + dictionary.bindEntity(Author.class); + + entityManager = mock(EntityManager.class); + query = mock(Query.class); + when(entityManager.createQuery(any(String.class))).thenReturn(query); + when(query.setParameter(any(String.class), any())).thenReturn(query); + when(query.setParameter(any(Integer.class), any())).thenReturn(query); + + scope = mock(RequestScope.class); + when(scope.getDictionary()).thenReturn(dictionary); + } + @ParameterizedTest @ValueSource(booleans = {true, false}) public void testNoDelegationOnLoadRecords(boolean delegateToInMemory) { - EntityManager entityManager = mock(EntityManager.class); AbstractJpaTransaction tx = new AbstractJpaTransaction(entityManager, (unused) -> { }, DEFAULT_LOGGER, delegateToInMemory) { @@ -45,55 +80,33 @@ public boolean isOpen() { public void begin() { } - }; - - RequestScope scope = mock(RequestScope.class); - EntityProjection projection = EntityProjection.builder() - .type(Book.class) - .build(); - assertEquals(DataStoreTransaction.FeatureSupport.FULL, - tx.supportsFiltering(scope, Optional.empty(), projection)); - assertTrue(tx.supportsSorting(scope, Optional.empty(), projection)); - assertTrue(tx.supportsPagination(scope, Optional.empty(), projection)); - } - - @Test - public void testDelegationOnCollectionOfCollectionsFetch() { - EntityManager entityManager = mock(EntityManager.class); - - AbstractJpaTransaction tx = new AbstractJpaTransaction(entityManager, (unused) -> { - - }, DEFAULT_LOGGER, true) { - @Override - public boolean isOpen() { - return false; - } @Override - public void begin() { - - } + protected Predicate> isPersistentCollection() { + return (unused) -> true; + }; }; - RequestScope scope = mock(RequestScope.class); EntityProjection projection = EntityProjection.builder() .type(Book.class) .build(); - Author author = mock(Author.class); - assertEquals(DataStoreTransaction.FeatureSupport.NONE, - tx.supportsFiltering(scope, Optional.of(author), projection)); - assertFalse(tx.supportsSorting(scope, Optional.of(author), projection)); - assertFalse(tx.supportsPagination(scope, Optional.of(author), projection)); + DataStoreIterable result = tx.loadObjects(projection, scope); + assertFalse(result.needsInMemoryFilter()); + assertFalse(result.needsInMemorySort()); + assertFalse(result.needsInMemoryPagination()); } - @Test - public void testNoDelegationOnCollectionOfCollectionsFetch() { - EntityManager entityManager = mock(EntityManager.class); - + @ParameterizedTest + @MethodSource("getTestArguments") + public void testGetRelationDelegation( + boolean delegateToInMemory, + int numberOfAuthors, + FilterExpression filter, + boolean usesInMemory) throws Exception { AbstractJpaTransaction tx = new AbstractJpaTransaction(entityManager, (unused) -> { - }, DEFAULT_LOGGER, false) { + }, DEFAULT_LOGGER, delegateToInMemory, false) { @Override public boolean isOpen() { return false; @@ -103,54 +116,65 @@ public boolean isOpen() { public void begin() { } + + @Override + protected Predicate> isPersistentCollection() { + return (unused) -> true; + }; }; - RequestScope scope = mock(RequestScope.class); EntityProjection projection = EntityProjection.builder() - .type(Book.class) + .type(Author.class) .build(); - Author author = mock(Author.class); - assertEquals(DataStoreTransaction.FeatureSupport.FULL, - tx.supportsFiltering(scope, Optional.of(author), projection)); - assertTrue(tx.supportsSorting(scope, Optional.of(author), projection)); - assertTrue(tx.supportsPagination(scope, Optional.of(author), projection)); - } - @ParameterizedTest - @ValueSource(booleans = {true, false}) - public void testNoDelegationOnCollectionOfOneFetch(boolean delegateToInMemory) throws Exception { - EntityDictionary dictionary = EntityDictionary.builder().build(); + List authors = new ArrayList<>(); + Author author1 = mock(Author.class); + authors.add(author1); + + for (int idx = 1; idx < numberOfAuthors; idx++) { + authors.add(mock(Author.class)); + } + + when (query.getResultList()).thenReturn(authors); - JpaDataStoreHarness harness = new JpaDataStoreHarness(DEFAULT_LOGGER, delegateToInMemory); - DataStore store = harness.getDataStore(); - store.populateEntityDictionary(dictionary); + DataStoreIterable loadedAuthors = tx.loadObjects(projection, scope); + assertFalse(loadedAuthors.needsInMemoryPagination()); + assertFalse(loadedAuthors.needsInMemorySort()); + assertFalse(loadedAuthors.needsInMemoryFilter()); - ElideSettings settings = new ElideSettingsBuilder(store) - .withEntityDictionary(dictionary) + Relationship relationship = Relationship.builder() + .name("books") + .projection(EntityProjection.builder() + .type(Book.class) + .filterExpression(filter) + .build()) .build(); - DataStoreTransaction writeTx = store.beginTransaction(); - RequestScope scope = new RequestScope("", "", "", null, writeTx, - null, null, null, null, settings); + PersistentSet returnCollection = mock(PersistentSet.class); - Author saveAuthor = new Author(); - writeTx.createObject(saveAuthor, scope); - writeTx.commit(scope); - writeTx.close(); + when(author1.getBooks()).thenReturn(returnCollection); - DataStoreTransaction readTx = store.beginReadTransaction(); - scope = new RequestScope("", "", "", null, readTx, - null, null, null, null, settings); + DataStoreIterable loadedBooks = tx.getToManyRelation(tx, author1, relationship, scope); - Author loadedAuthor = readTx.loadObject(EntityProjection.builder().type(Author.class).build(), 1L, scope); + assertEquals(usesInMemory, loadedBooks.needsInMemoryFilter()); + assertEquals(usesInMemory, loadedBooks.needsInMemorySort()); + assertEquals(usesInMemory, loadedBooks.needsInMemoryPagination()); + } - EntityProjection projection = EntityProjection.builder() - .type(Book.class) - .build(); - assertEquals(DataStoreTransaction.FeatureSupport.FULL, - readTx.supportsFiltering(scope, Optional.of(loadedAuthor), projection)); - assertTrue(readTx.supportsSorting(scope, Optional.of(loadedAuthor), projection)); - assertTrue(readTx.supportsPagination(scope, Optional.of(loadedAuthor), projection)); + private Stream getTestArguments() throws Exception { + RSQLFilterDialect parser = RSQLFilterDialect.builder().dictionary(dictionary).build(); + + FilterExpression expression = parser.parse(ClassType.of(Book.class), Collections.emptySet(), "title=='foo'", NO_VERSION); + return Stream.of( + arguments(true, 1, null, true), + arguments(true, 2, null, true), + arguments(false, 1, null, true), + arguments(false, 2, null, true), + arguments(true, 1, expression, false), + arguments(true, 2, expression, true), + arguments(false, 1, expression, false), + arguments(false, 2, expression, false) + ); } } diff --git a/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/usertypes/JsonType.java b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/usertypes/JsonType.java new file mode 100644 index 0000000000..e625e20035 --- /dev/null +++ b/elide-datastore/elide-datastore-jpa/src/test/java/com/yahoo/elide/datastores/jpa/usertypes/JsonType.java @@ -0,0 +1,167 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.datastores.jpa.usertypes; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.hibernate.HibernateException; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.usertype.ParameterizedType; +import org.hibernate.usertype.UserType; + +import java.io.IOException; +import java.io.Serializable; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Objects; +import java.util.Properties; + +/** + * JsonType serializes an object to json string and vice versa. + */ + +public class JsonType implements UserType, ParameterizedType { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private Class objectClass; + + /** + * {@inheritDoc} + */ + @Override + public int[] sqlTypes() { + return new int[]{Types.LONGVARCHAR}; + } + + /** + * {@inheritDoc} + */ + @Override + public Class returnedClass() { + return String.class; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object firstObject, Object secondObject) + throws HibernateException { + + return Objects.equals(firstObject, secondObject); + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode(Object object) + throws HibernateException { + + return Objects.hashCode(object); + } + + /** + * {@inheritDoc} + */ + @Override + public Object nullSafeGet(ResultSet resultSet, String[] names, SharedSessionContractImplementor sharedSessionContractImplementor, Object o) throws HibernateException, SQLException { + if (resultSet.getString(names[0]) != null) { + + // Get the rawJson + String rawJson = resultSet.getString(names[0]); + + try { + return MAPPER.readValue(rawJson, this.objectClass); + } catch (IOException e) { + throw new HibernateException("Could not retrieve an instance of the mapped class from a JDBC resultset."); + } + } + return null; + } + + /** + * {@inheritDoc} + */ + @Override + public void nullSafeSet(PreparedStatement preparedStatement, Object value, int i, SharedSessionContractImplementor sharedSessionContractImplementor) throws HibernateException, SQLException { + if (value == null) { + preparedStatement.setNull(i, Types.NULL); + } else { + try { + String json = MAPPER.writeValueAsString(value); + preparedStatement.setString(i, json); + } catch (JsonProcessingException e) { + throw new HibernateException("Could not write an instance of the mapped class to a prepared statement."); + } + } + } + + /** + * {@inheritDoc} + */ + @Override + public Object deepCopy(Object object) + throws HibernateException { + + // Since mutable is false, return the object + return object; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isMutable() { + return false; + } + + /** + * {@inheritDoc} + */ + @Override + public Serializable disassemble(Object value) + throws HibernateException { + + return value == null ? null : (Serializable) deepCopy(value); + } + + /** + * {@inheritDoc} + */ + @Override + public Object assemble(Serializable cached, Object owner) + throws HibernateException { + + return cached == null ? null : deepCopy(cached); + } + + /** + * {@inheritDoc} + */ + @Override + public Object replace(Object original, Object target, Object owner) + throws HibernateException { + + return deepCopy(original); + } + + /** + * Setter used to set the class to serialize/deserialize. + * @param properties properties object + */ + @Override + public void setParameterValues(Properties properties) { + try { + this.objectClass = Class.forName(properties.getProperty("class")); + } catch (ClassNotFoundException e) { + throw new RuntimeException("Unable set the `class` parameter for serialization/deserialization"); + } + } +} diff --git a/elide-datastore/elide-datastore-jpa/src/test/java/example/Person.java b/elide-datastore/elide-datastore-jpa/src/test/java/example/Person.java index 638847ec40..36ddc55eeb 100644 --- a/elide-datastore/elide-datastore-jpa/src/test/java/example/Person.java +++ b/elide-datastore/elide-datastore-jpa/src/test/java/example/Person.java @@ -6,9 +6,10 @@ package example; import com.yahoo.elide.annotation.Include; +import org.hibernate.annotations.Parameter; +import org.hibernate.annotations.Type; import org.hibernate.envers.Audited; -import lombok.Getter; -import lombok.Setter; +import lombok.Data; import javax.persistence.Column; import javax.persistence.Convert; @@ -18,25 +19,22 @@ @Entity @Include @Audited // Ensure envers does not cause any issues +@Data public class Person { - @Setter + @Id private long id; - @Setter - @Getter private String name; - @Setter - private AddressFragment address; - - @Id - public long getId() { - return id; - } - + //For testing Convert annotation @Column(name = "address", columnDefinition = "TEXT") @Convert(converter = AddressFragment.Converter.class) - public AddressFragment getAddress() { - return address; - } + private AddressFragment address; + + //For testing Type annotation + @Column(name = "addressAlternate", columnDefinition = "TEXT") + @Type(type = "com.yahoo.elide.datastores.jpa.usertypes.JsonType", parameters = { + @Parameter(name = "class", value = "example.AddressFragment") + }) + private AddressFragment alternateAddress; } diff --git a/elide-datastore/elide-datastore-jpql/pom.xml b/elide-datastore/elide-datastore-jpql/pom.xml index 91e2af4d86..cc94886673 100644 --- a/elide-datastore/elide-datastore-jpql/pom.xml +++ b/elide-datastore/elide-datastore-jpql/pom.xml @@ -14,7 +14,7 @@ com.yahoo.elide elide-datastore-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -58,7 +58,7 @@ com.yahoo.elide elide-integration-tests - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT test-jar test diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/JPQLTransaction.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/JPQLTransaction.java index 0ce794ba37..c54de6051c 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/JPQLTransaction.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/JPQLTransaction.java @@ -7,6 +7,8 @@ import com.yahoo.elide.core.Path; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.filter.expression.AndFilterExpression; @@ -35,7 +37,6 @@ import java.util.Collections; import java.util.IdentityHashMap; import java.util.Iterator; -import java.util.Optional; import java.util.Set; import java.util.function.Predicate; @@ -107,11 +108,15 @@ public T loadObject(EntityProjection projection, new RootCollectionFetchQueryBuilder(projection, dictionary, sessionWrapper).build(); T loaded = new TimedFunction(() -> query.uniqueResult(), "Query Hash: " + query.hashCode()).get(); - return addSingleElement(loaded); + + if (loaded != null) { + singleElementLoads.add(loaded); + } + return loaded; } @Override - public Iterable loadObjects( + public DataStoreIterable loadObjects( EntityProjection projection, RequestScope scope) { @@ -141,11 +146,11 @@ public Iterable loadObjects( } } - return addSingleElement(results); + return new DataStoreIterableBuilder(addSingleElement(results)).build(); } @Override - public R getRelation( + public DataStoreIterable getToManyRelation( DataStoreTransaction relationTx, T entity, Relationship relation, @@ -156,8 +161,10 @@ public R getRelation( Pagination pagination = relation.getProjection().getPagination(); EntityDictionary dictionary = scope.getDictionary(); - Object val = com.yahoo.elide.core.PersistentResource.getValue(entity, relation.getName(), scope); - if (val instanceof Collection && isPersistentCollection().test((Collection) val)) { + Iterable val = (Iterable) com.yahoo.elide.core.PersistentResource.getValue(entity, relation.getName(), scope); + + //If the query is safe for N+1 and the value is an ORM managed, persistent collection, run a JPQL query... + if (doInDatabase(entity) && val instanceof Collection && isPersistentCollection().test((Collection) val)) { /* * If there is no filtering or sorting required in the data store, and the pagination is default, @@ -165,7 +172,7 @@ public R getRelation( */ if (filterExpression == null && sorting == null && (pagination == null || (pagination.isDefaultInstance()))) { - return addSingleElement((R) val); + return new DataStoreIterableBuilder(addSingleElement(val)).allInMemory().build(); } RelationshipImpl relationship = new RelationshipImpl( @@ -184,10 +191,24 @@ public R getRelation( .build(); if (query != null) { - return addSingleElement((R) query.list()); + return new DataStoreIterableBuilder(addSingleElement(query.list())).build(); } } - return addSingleElement((R) val); + return new DataStoreIterableBuilder(addSingleElement(val)).allInMemory().build(); + } + + @Override + public R getToOneRelation( + DataStoreTransaction relationTx, + T entity, + Relationship relationship, + RequestScope scope + ) { + R loaded = DataStoreTransaction.super.getToOneRelation(relationTx, entity, relationship, scope); + if (loaded != null) { + singleElementLoads.add(loaded); + } + return loaded; } protected abstract Predicate> isPersistentCollection(); @@ -226,40 +247,20 @@ private Long getTotalRecords(AbstractHQLQueryBuilder.Relationship relationship, return new TimedFunction(() -> query.uniqueResult(), "Query Hash: " + query.hashCode()).get(); } - private R addSingleElement(R results) { - if (results instanceof Iterable) { - if (results instanceof ScrollableIteratorBase) { - ((ScrollableIteratorBase) results).singletonElement().ifPresent(singleElementLoads::add); - } else if (results instanceof Collection && ((Collection) results).size() == 1) { - ((Collection) results).forEach(singleElementLoads::add); - } - } else if (results != null) { - singleElementLoads.add(results); + private Iterable addSingleElement(Iterable results) { + if (results instanceof ScrollableIteratorBase) { + ((ScrollableIteratorBase) results).singletonElement().ifPresent(singleElementLoads::add); + } else if (results instanceof Collection && ((Collection) results).size() == 1) { + ((Collection) results).forEach(singleElementLoads::add); } + return results; } - protected boolean doInDatabase(Optional parent) { + protected boolean doInDatabase(T parent) { // In-Memory delegation is disabled. return !delegateToInMemoryStore - // This is a root level load (so always let the DB do as much as possible. - || !parent.isPresent() // We are fetching .../book/1/authors so N = 1 in N+1. No harm in the DB running a query. - || parent.filter(singleElementLoads::contains).isPresent(); - } - - @Override - public FeatureSupport supportsFiltering(RequestScope scope, Optional parent, EntityProjection projection) { - return doInDatabase(parent) ? FeatureSupport.FULL : FeatureSupport.NONE; - } - - @Override - public boolean supportsSorting(RequestScope scope, Optional parent, EntityProjection projection) { - return doInDatabase(parent); - } - - @Override - public boolean supportsPagination(RequestScope scope, Optional parent, EntityProjection projection) { - return doInDatabase(parent); + || singleElementLoads.contains(parent); } } diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/filter/FilterTranslator.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/filter/FilterTranslator.java index 382901e7c4..0aee9b139e 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/filter/FilterTranslator.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/filter/FilterTranslator.java @@ -23,7 +23,13 @@ import static com.yahoo.elide.core.filter.Operator.NOTBETWEEN; import static com.yahoo.elide.core.filter.Operator.NOTEMPTY; import static com.yahoo.elide.core.filter.Operator.NOTNULL; +import static com.yahoo.elide.core.filter.Operator.NOT_INFIX; +import static com.yahoo.elide.core.filter.Operator.NOT_INFIX_CASE_INSENSITIVE; import static com.yahoo.elide.core.filter.Operator.NOT_INSENSITIVE; +import static com.yahoo.elide.core.filter.Operator.NOT_POSTFIX; +import static com.yahoo.elide.core.filter.Operator.NOT_POSTFIX_CASE_INSENSITIVE; +import static com.yahoo.elide.core.filter.Operator.NOT_PREFIX; +import static com.yahoo.elide.core.filter.Operator.NOT_PREFIX_CASE_INSENSITIVE; import static com.yahoo.elide.core.filter.Operator.POSTFIX; import static com.yahoo.elide.core.filter.Operator.POSTFIX_CASE_INSENSITIVE; import static com.yahoo.elide.core.filter.Operator.PREFIX; @@ -31,6 +37,7 @@ import static com.yahoo.elide.core.filter.Operator.TRUE; import static com.yahoo.elide.core.utils.TypeHelper.getFieldAlias; import static com.yahoo.elide.core.utils.TypeHelper.getPathAlias; + import com.yahoo.elide.core.Path; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.BadRequestException; @@ -44,7 +51,9 @@ import com.yahoo.elide.core.filter.predicates.FilterPredicate; import com.yahoo.elide.core.filter.predicates.FilterPredicate.FilterParameter; import com.yahoo.elide.core.type.Type; + import com.google.common.base.Preconditions; + import org.apache.commons.lang3.tuple.Triple; import java.util.EnumMap; @@ -60,86 +69,119 @@ public class FilterTranslator implements FilterOperation { private static final String COMMA = ", "; - private static Map globalOperatorGenerators; - private static Map, String>, JPQLPredicateGenerator> globalPredicateOverrides; - - private Map operatorGenerators; - private Map, String>, JPQLPredicateGenerator> predicateOverrides; + private static final Map GLOBAL_OPERATOR_GENERATORS; + private static final Map, String>, JPQLPredicateGenerator> GLOBAL_PREDICATE_OVERRIDES; static { - globalPredicateOverrides = new HashMap<>(); + GLOBAL_PREDICATE_OVERRIDES = new HashMap<>(); - globalOperatorGenerators = new EnumMap<>(Operator.class); + GLOBAL_OPERATOR_GENERATORS = new EnumMap<>(Operator.class); - globalOperatorGenerators.put(IN, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(IN, new CaseAwareJPQLGenerator( "%s IN (%s)", CaseAwareJPQLGenerator.Case.NONE, CaseAwareJPQLGenerator.ArgumentCount.MANY) ); - globalOperatorGenerators.put(IN_INSENSITIVE, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(IN_INSENSITIVE, new CaseAwareJPQLGenerator( "%s IN (%s)", CaseAwareJPQLGenerator.Case.LOWER, CaseAwareJPQLGenerator.ArgumentCount.MANY) ); - globalOperatorGenerators.put(NOT, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(NOT, new CaseAwareJPQLGenerator( "%s NOT IN (%s)", CaseAwareJPQLGenerator.Case.NONE, CaseAwareJPQLGenerator.ArgumentCount.MANY) ); - globalOperatorGenerators.put(NOT_INSENSITIVE, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(NOT_INSENSITIVE, new CaseAwareJPQLGenerator( "%s NOT IN (%s)", CaseAwareJPQLGenerator.Case.LOWER, CaseAwareJPQLGenerator.ArgumentCount.MANY) ); - globalOperatorGenerators.put(PREFIX, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(PREFIX, new CaseAwareJPQLGenerator( "%s LIKE CONCAT(%s, '%%')", CaseAwareJPQLGenerator.Case.NONE, CaseAwareJPQLGenerator.ArgumentCount.ONE) ); - globalOperatorGenerators.put(PREFIX_CASE_INSENSITIVE, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(NOT_PREFIX, new CaseAwareJPQLGenerator( + "%s NOT LIKE CONCAT(%s, '%%')", + CaseAwareJPQLGenerator.Case.NONE, + CaseAwareJPQLGenerator.ArgumentCount.ONE) + ); + + GLOBAL_OPERATOR_GENERATORS.put(PREFIX_CASE_INSENSITIVE, new CaseAwareJPQLGenerator( "%s LIKE CONCAT(%s, '%%')", CaseAwareJPQLGenerator.Case.LOWER, CaseAwareJPQLGenerator.ArgumentCount.ONE) ); - globalOperatorGenerators.put(POSTFIX, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(NOT_PREFIX_CASE_INSENSITIVE, new CaseAwareJPQLGenerator( + "%s NOT LIKE CONCAT(%s, '%%')", + CaseAwareJPQLGenerator.Case.LOWER, + CaseAwareJPQLGenerator.ArgumentCount.ONE) + ); + + GLOBAL_OPERATOR_GENERATORS.put(POSTFIX, new CaseAwareJPQLGenerator( "%s LIKE CONCAT('%%', %s)", CaseAwareJPQLGenerator.Case.NONE, CaseAwareJPQLGenerator.ArgumentCount.ONE) ); - globalOperatorGenerators.put(POSTFIX_CASE_INSENSITIVE, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(NOT_POSTFIX, new CaseAwareJPQLGenerator( + "%s NOT LIKE CONCAT('%%', %s)", + CaseAwareJPQLGenerator.Case.NONE, + CaseAwareJPQLGenerator.ArgumentCount.ONE) + ); + + GLOBAL_OPERATOR_GENERATORS.put(POSTFIX_CASE_INSENSITIVE, new CaseAwareJPQLGenerator( "%s LIKE CONCAT('%%', %s)", CaseAwareJPQLGenerator.Case.LOWER, CaseAwareJPQLGenerator.ArgumentCount.ONE) ); - globalOperatorGenerators.put(INFIX, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(NOT_POSTFIX_CASE_INSENSITIVE, new CaseAwareJPQLGenerator( + "%s NOT LIKE CONCAT('%%', %s)", + CaseAwareJPQLGenerator.Case.LOWER, + CaseAwareJPQLGenerator.ArgumentCount.ONE) + ); + + GLOBAL_OPERATOR_GENERATORS.put(INFIX, new CaseAwareJPQLGenerator( "%s LIKE CONCAT('%%', CONCAT(%s, '%%'))", CaseAwareJPQLGenerator.Case.NONE, CaseAwareJPQLGenerator.ArgumentCount.ONE) ); - globalOperatorGenerators.put(INFIX_CASE_INSENSITIVE, new CaseAwareJPQLGenerator( + GLOBAL_OPERATOR_GENERATORS.put(NOT_INFIX, new CaseAwareJPQLGenerator( + "%s NOT LIKE CONCAT('%%', CONCAT(%s, '%%'))", + CaseAwareJPQLGenerator.Case.NONE, + CaseAwareJPQLGenerator.ArgumentCount.ONE) + ); + + GLOBAL_OPERATOR_GENERATORS.put(INFIX_CASE_INSENSITIVE, new CaseAwareJPQLGenerator( "%s LIKE CONCAT('%%', CONCAT(%s, '%%'))", CaseAwareJPQLGenerator.Case.LOWER, CaseAwareJPQLGenerator.ArgumentCount.ONE) ); - globalOperatorGenerators.put(LT, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(NOT_INFIX_CASE_INSENSITIVE, new CaseAwareJPQLGenerator( + "%s NOT LIKE CONCAT('%%', CONCAT(%s, '%%'))", + CaseAwareJPQLGenerator.Case.LOWER, + CaseAwareJPQLGenerator.ArgumentCount.ONE) + ); + + GLOBAL_OPERATOR_GENERATORS.put(LT, (predicate, aliasGenerator) -> { Preconditions.checkState(!predicate.getParameters().isEmpty()); return String.format("%s < %s", aliasGenerator.apply(predicate.getPath()), predicate.getParameters().size() == 1 - ? predicate.getParameters().get(0).getPlaceholder() - : leastClause(predicate.getParameters())); + ? predicate.getParameters().get(0).getPlaceholder() + : leastClause(predicate.getParameters())); }); - globalOperatorGenerators.put(LE, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(LE, (predicate, aliasGenerator) -> { Preconditions.checkState(!predicate.getParameters().isEmpty()); return String.format("%s <= %s", aliasGenerator.apply(predicate.getPath()), predicate.getParameters().size() == 1 @@ -147,7 +189,7 @@ public class FilterTranslator implements FilterOperation { : leastClause(predicate.getParameters())); }); - globalOperatorGenerators.put(GT, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(GT, (predicate, aliasGenerator) -> { Preconditions.checkState(!predicate.getParameters().isEmpty()); return String.format("%s > %s", aliasGenerator.apply(predicate.getPath()), predicate.getParameters().size() == 1 @@ -156,7 +198,7 @@ public class FilterTranslator implements FilterOperation { }); - globalOperatorGenerators.put(GE, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(GE, (predicate, aliasGenerator) -> { Preconditions.checkState(!predicate.getParameters().isEmpty()); return String.format("%s >= %s", aliasGenerator.apply(predicate.getPath()), predicate.getParameters().size() == 1 @@ -164,31 +206,31 @@ public class FilterTranslator implements FilterOperation { : greatestClause(predicate.getParameters())); }); - globalOperatorGenerators.put(ISNULL, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(ISNULL, (predicate, aliasGenerator) -> { return String.format("%s IS NULL", aliasGenerator.apply(predicate.getPath())); }); - globalOperatorGenerators.put(NOTNULL, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(NOTNULL, (predicate, aliasGenerator) -> { return String.format("%s IS NOT NULL", aliasGenerator.apply(predicate.getPath())); }); - globalOperatorGenerators.put(TRUE, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(TRUE, (predicate, aliasGenerator) -> { return "(1 = 1)"; }); - globalOperatorGenerators.put(FALSE, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(FALSE, (predicate, aliasGenerator) -> { return "(1 = 0)"; }); - globalOperatorGenerators.put(ISEMPTY, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(ISEMPTY, (predicate, aliasGenerator) -> { return String.format("%s IS EMPTY", aliasGenerator.apply(predicate.getPath())); }); - globalOperatorGenerators.put(NOTEMPTY, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(NOTEMPTY, (predicate, aliasGenerator) -> { return String.format("%s IS NOT EMPTY", aliasGenerator.apply(predicate.getPath())); }); - globalOperatorGenerators.put(BETWEEN, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(BETWEEN, (predicate, aliasGenerator) -> { List parameters = predicate.getParameters(); Preconditions.checkState(!parameters.isEmpty()); Preconditions.checkArgument(parameters.size() == 2); @@ -198,7 +240,7 @@ public class FilterTranslator implements FilterOperation { parameters.get(1).getPlaceholder()); }); - globalOperatorGenerators.put(NOTBETWEEN, (predicate, aliasGenerator) -> { + GLOBAL_OPERATOR_GENERATORS.put(NOTBETWEEN, (predicate, aliasGenerator) -> { List parameters = predicate.getParameters(); Preconditions.checkState(!parameters.isEmpty()); Preconditions.checkArgument(parameters.size() == 2); @@ -209,14 +251,18 @@ public class FilterTranslator implements FilterOperation { }); } + private final Map operatorGenerators; + private final Map, String>, JPQLPredicateGenerator> predicateOverrides; + /** * Overrides the default JPQL generator for a given operator. - * @param op The filter predicate operator + * + * @param op The filter predicate operator * @param generator The generator to resgister */ public static void registerJPQLGenerator(Operator op, JPQLPredicateGenerator generator) { - globalOperatorGenerators.put(op, generator); + GLOBAL_OPERATOR_GENERATORS.put(op, generator); } /** @@ -230,7 +276,7 @@ public static void registerJPQLGenerator(Operator op, Type entityClass, String fieldName, JPQLPredicateGenerator generator) { - globalPredicateOverrides.put(Triple.of(op, entityClass, fieldName), generator); + GLOBAL_PREDICATE_OVERRIDES.put(Triple.of(op, entityClass, fieldName), generator); } /** @@ -243,7 +289,7 @@ public static void registerJPQLGenerator(Operator op, public static JPQLPredicateGenerator lookupJPQLGenerator(Operator op, Type entityClass, String fieldName) { - return globalPredicateOverrides.get(Triple.of(op, entityClass, fieldName)); + return GLOBAL_PREDICATE_OVERRIDES.get(Triple.of(op, entityClass, fieldName)); } /** @@ -253,7 +299,7 @@ public static JPQLPredicateGenerator lookupJPQLGenerator(Operator op, * @return Returns null if no generator is registered. */ public static JPQLPredicateGenerator lookupJPQLGenerator(Operator op) { - return globalOperatorGenerators.get(op); + return GLOBAL_OPERATOR_GENERATORS.get(op); } private final EntityDictionary dictionary; @@ -264,15 +310,15 @@ public static JPQLPredicateGenerator lookupJPQLGenerator(Operator op) { */ public FilterTranslator(EntityDictionary dictionary) { this.dictionary = dictionary; - if (! globalOperatorGenerators.containsKey(HASMEMBER)) { - globalOperatorGenerators.put(HASMEMBER, new HasMemberJPQLGenerator(dictionary)); + if (! GLOBAL_OPERATOR_GENERATORS.containsKey(HASMEMBER)) { + GLOBAL_OPERATOR_GENERATORS.put(HASMEMBER, new HasMemberJPQLGenerator(dictionary)); } - if (! globalOperatorGenerators.containsKey(HASNOMEMBER)) { - globalOperatorGenerators.put(HASNOMEMBER, new HasMemberJPQLGenerator(dictionary, true)); + if (! GLOBAL_OPERATOR_GENERATORS.containsKey(HASNOMEMBER)) { + GLOBAL_OPERATOR_GENERATORS.put(HASNOMEMBER, new HasMemberJPQLGenerator(dictionary, true)); } - this.operatorGenerators = new HashMap<>(globalOperatorGenerators); - this.predicateOverrides = new HashMap<>(globalPredicateOverrides); + this.operatorGenerators = new HashMap<>(GLOBAL_OPERATOR_GENERATORS); + this.predicateOverrides = new HashMap<>(GLOBAL_PREDICATE_OVERRIDES); } /** @@ -383,7 +429,7 @@ public String apply(FilterExpression filterExpression, Function al * Filter expression visitor which builds an JPQL query. */ public class JPQLQueryVisitor implements FilterExpressionVisitor { - private Function aliasGenerator; + private final Function aliasGenerator; public JPQLQueryVisitor(Function aliasGenerator) { this.aliasGenerator = aliasGenerator; diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/Query.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/Query.java index 34366ad70f..c692ffcc97 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/Query.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/Query.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/QueryLogger.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/QueryLogger.java index 65c19cbe09..6bc58f4cf3 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/QueryLogger.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/QueryLogger.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Oath Inc. + * Copyright 2021, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/Session.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/Session.java index 74b8b287e1..6ccbce2e04 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/Session.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/porting/Session.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/AbstractHQLQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/AbstractHQLQueryBuilder.java index 4f7f1fb82a..8618d9de89 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/AbstractHQLQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/AbstractHQLQueryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/DefaultQueryLogger.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/DefaultQueryLogger.java index 859d13b68b..2016549e83 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/DefaultQueryLogger.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/DefaultQueryLogger.java @@ -1,5 +1,5 @@ /* - * Copyright 2021, Oath Inc. + * Copyright 2021, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RelationshipImpl.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RelationshipImpl.java index 363415d907..2a7f525c6b 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RelationshipImpl.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RelationshipImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionFetchQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionFetchQueryBuilder.java index e9088787f8..147e84d6d1 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionFetchQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionFetchQueryBuilder.java @@ -1,11 +1,12 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.datastores.jpql.query; import static com.yahoo.elide.core.utils.TypeHelper.getTypeAlias; + import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.InvalidValueException; import com.yahoo.elide.core.filter.expression.FilterExpression; @@ -51,16 +52,20 @@ public Query build() { String filterClause = WHERE + new FilterTranslator(dictionary).apply(filterExpression, USE_ALIAS); //Build the JOIN clause - String joinClause = getJoinClauseFromFilters(filterExpression) + String joinClause = getJoinClauseFromFilters(filterExpression) + getJoinClauseFromSort(entityProjection.getSorting()) + extractToOneMergeJoins(entityClass, entityAlias); - boolean requiresDistinct = entityProjection.getPagination() != null - && containsOneToMany(filterExpression); + boolean requiresDistinct = containsOneToMany(filterExpression); - Boolean sortOverRelationship = entityProjection.getSorting() != null + boolean sortOverRelationship = entityProjection.getSorting() != null && entityProjection.getSorting().getSortingPaths().keySet() - .stream().anyMatch(path -> path.getPathElements().size() > 1); + .stream().anyMatch(path -> + path.getPathElements() + .stream() + .anyMatch(element -> + dictionary.isRelation(element.getType(), element.getFieldName()))); + if (requiresDistinct && sortOverRelationship) { //SQL does not support distinct and order by on columns which are not selected throw new InvalidValueException("Combination of pagination, sorting over relationship and" diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionPageTotalsQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionPageTotalsQueryBuilder.java index 55c0009156..275b106a15 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionPageTotalsQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/RootCollectionPageTotalsQueryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionFetchQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionFetchQueryBuilder.java index ce305129c8..93d4f3f6e1 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionFetchQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionFetchQueryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionPageTotalsQueryBuilder.java b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionPageTotalsQueryBuilder.java index e687d94cca..f723a94188 100644 --- a/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionPageTotalsQueryBuilder.java +++ b/elide-datastore/elide-datastore-jpql/src/main/java/com/yahoo/elide/datastores/jpql/query/SubCollectionPageTotalsQueryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/AbstractHQLQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/AbstractHQLQueryBuilderTest.java index b8f5ca7065..12f9eb10f6 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/AbstractHQLQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/AbstractHQLQueryBuilderTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionFetchQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionFetchQueryBuilderTest.java index 3652b1924f..559341fcde 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionFetchQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionFetchQueryBuilderTest.java @@ -1,13 +1,16 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.datastores.hibernate.hql; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + import com.yahoo.elide.core.Path; import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.InvalidValueException; import com.yahoo.elide.core.filter.dialect.CaseSensitivityStrategy; import com.yahoo.elide.core.filter.dialect.ParseException; import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; @@ -23,16 +26,19 @@ import com.yahoo.elide.core.sort.SortingImpl; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.datastores.jpql.query.RootCollectionFetchQueryBuilder; + import example.Author; import example.Book; import example.Chapter; import example.Editor; import example.Publisher; + import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import java.util.HashMap; +import java.util.List; import java.util.Map; @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -45,6 +51,8 @@ public class RootCollectionFetchQueryBuilderTest { private static final String PERIOD = "."; private static final String NAME = "name"; private static final String FIRSTNAME = "firstName"; + private static final String PRICE = "price"; + private static final String TOTAL = "total"; private RSQLFilterDialect filterParser; @BeforeAll @@ -55,7 +63,10 @@ public void initialize() { dictionary.bindEntity(Publisher.class); dictionary.bindEntity(Chapter.class); dictionary.bindEntity(Editor.class); - filterParser = new RSQLFilterDialect(dictionary, new CaseSensitivityStrategy.UseColumnCollation()); + filterParser = RSQLFilterDialect.builder() + .dictionary(dictionary) + .caseSensitivityStrategy(new CaseSensitivityStrategy.UseColumnCollation()) + .build(); } @Test @@ -128,12 +139,12 @@ public void testRootFetchWithJoinFilter() throws ParseException { String expected = - "SELECT example_Author FROM example.Author AS example_Author " - + "LEFT JOIN example_Author.books example_Author_books " - + "LEFT JOIN example_Author_books.chapters example_Author_books_chapters " - + "LEFT JOIN example_Author_books.publisher example_Author_books_publisher " - + "WHERE (example_Author_books_chapters.title IN (:books_chapters_title_XXX, :books_chapters_title_XXX) " - + "OR example_Author_books_publisher.name IN (:books_publisher_name_XXX)) "; + "SELECT DISTINCT example_Author FROM example.Author AS example_Author " + + "LEFT JOIN example_Author.books example_Author_books " + + "LEFT JOIN example_Author_books.chapters example_Author_books_chapters " + + "LEFT JOIN example_Author_books.publisher example_Author_books_publisher " + + "WHERE (example_Author_books_chapters.title IN (:books_chapters_title_XXX, :books_chapters_title_XXX) " + + "OR example_Author_books_publisher.name IN (:books_publisher_name_XXX)) "; String actual = query.getQueryText(); actual = actual.replaceFirst(":books_chapters_title_\\w\\w\\w\\w+", ":books_chapters_title_XXX"); @@ -169,11 +180,11 @@ public void testDistinctRootFetchWithToManyJoinFilterAndPagination() throws Pars String expected = "SELECT DISTINCT example_Author FROM example.Author AS example_Author " - + "LEFT JOIN example_Author.books example_Author_books " - + "LEFT JOIN example_Author_books.chapters example_Author_books_chapters " - + "LEFT JOIN example_Author_books.publisher example_Author_books_publisher " - + "WHERE (example_Author_books_chapters.title IN (:books_chapters_title_XXX, :books_chapters_title_XXX) " - + "OR example_Author_books_publisher.name IN (:books_publisher_name_XXX))"; + + "LEFT JOIN example_Author.books example_Author_books " + + "LEFT JOIN example_Author_books.chapters example_Author_books_chapters " + + "LEFT JOIN example_Author_books.publisher example_Author_books_publisher " + + "WHERE (example_Author_books_chapters.title IN (:books_chapters_title_XXX, :books_chapters_title_XXX) " + + "OR example_Author_books_publisher.name IN (:books_publisher_name_XXX))"; String actual = query.getQueryText(); actual = actual.trim().replaceAll(" +", " "); @@ -210,7 +221,7 @@ public void testRootFetchWithSortingAndFilters() { String expected = "SELECT example_Book FROM example.Book AS example_Book" - + " WHERE example_Book.id IN (:id_XXX) order by example_Book.title asc"; + + " WHERE example_Book.id IN (:id_XXX) order by example_Book.title asc"; String actual = query.getQueryText(); actual = actual.trim().replaceAll(" +", " "); @@ -311,4 +322,76 @@ public void testRootFetchWithToOneRelationIncluded() { assertEquals(expected, actual); } + + @Test + public void testRootFetchWithRelationshipSortingFiltersAndPagination() { + Map sorting = new HashMap<>(); + sorting.put(PUBLISHER + PERIOD + EDITOR + PERIOD + FIRSTNAME, Sorting.SortOrder.desc); + + Path.PathElement idBook = new Path.PathElement(Book.class, Chapter.class, "chapters"); + Path.PathElement idChapter = new Path.PathElement(Chapter.class, long.class, "id"); + Path idPath = new Path(List.of(new Path.PathElement[]{idBook, idChapter})); + + FilterPredicate chapterIdPredicate = new InPredicate(idPath, 1); + + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 3, 10, 10, true, false); + + EntityProjection entityProjection = EntityProjection + .builder().type(Book.class) + .pagination(pagination) + .sorting(new SortingImpl(sorting, Book.class, dictionary)) + .filterExpression(chapterIdPredicate) + .build(); + + RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( + entityProjection, + dictionary, + new TestSessionWrapper() + ); + + assertThrows(InvalidValueException.class, () -> { + TestQueryWrapper build = (TestQueryWrapper) builder.build(); + }); + } + + @Test + public void testRootFetchWithRelationshipSortingFiltersAndPaginationOnEmbedded() { + Map sorting = new HashMap<>(); + sorting.put(PRICE + PERIOD + TOTAL, Sorting.SortOrder.desc); + + Path.PathElement idBook = new Path.PathElement(Book.class, Chapter.class, "chapters"); + Path.PathElement idChapter = new Path.PathElement(Chapter.class, long.class, "id"); + Path idPath = new Path(List.of(new Path.PathElement[]{idBook, idChapter})); + + FilterPredicate chapterIdPredicate = new InPredicate(idPath, 1); + + PaginationImpl pagination = new PaginationImpl(ClassType.of(Book.class), 0, 3, 10, 10, true, false); + + EntityProjection entityProjection = EntityProjection + .builder().type(Book.class) + .pagination(pagination) + .sorting(new SortingImpl(sorting, Book.class, dictionary)) + .filterExpression(chapterIdPredicate) + .build(); + + RootCollectionFetchQueryBuilder builder = new RootCollectionFetchQueryBuilder( + entityProjection, + dictionary, + new TestSessionWrapper() + ); + + TestQueryWrapper query = (TestQueryWrapper) builder.build(); + + String expected = + "SELECT DISTINCT example_Book FROM example.Book AS example_Book" + + " LEFT JOIN example_Book.chapters example_Book_chapters" + + " WHERE example_Book_chapters.id IN (:chapters_id_XXX)" + + " order by example_Book.price.total desc"; + + String actual = query.getQueryText(); + actual = actual.trim().replaceAll(" +", " "); + actual = actual.replaceFirst(":chapters_id_\\w+", ":chapters_id_XXX"); + + assertEquals(expected, actual); + } } diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionPageTotalsQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionPageTotalsQueryBuilderTest.java index 323c1759fc..77d3769542 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionPageTotalsQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/RootCollectionPageTotalsQueryBuilderTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionFetchQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionFetchQueryBuilderTest.java index 04b7eea26c..9beae973c6 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionFetchQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionFetchQueryBuilderTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionPageTotalsQueryBuilderTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionPageTotalsQueryBuilderTest.java index 26c0b5b132..bd29129858 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionPageTotalsQueryBuilderTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/SubCollectionPageTotalsQueryBuilderTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/TestQueryWrapper.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/TestQueryWrapper.java index a76edf0071..ac99575572 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/TestQueryWrapper.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/TestQueryWrapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/TestSessionWrapper.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/TestSessionWrapper.java index 3540457bfc..c40e9e29dc 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/TestSessionWrapper.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/hibernate/hql/TestSessionWrapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/jpql/filter/FilterTranslatorTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/jpql/filter/FilterTranslatorTest.java index ffbea31a82..606e990f9a 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/jpql/filter/FilterTranslatorTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/jpql/filter/FilterTranslatorTest.java @@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; + import com.yahoo.elide.core.Path; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.InvalidValueException; @@ -20,8 +21,10 @@ import com.yahoo.elide.core.filter.predicates.InPredicate; import com.yahoo.elide.core.filter.predicates.NotEmptyPredicate; import com.yahoo.elide.core.type.ClassType; + import example.Author; import example.Book; + import org.junit.jupiter.api.Test; import java.util.Arrays; @@ -35,15 +38,15 @@ */ public class FilterTranslatorTest { - private EntityDictionary dictionary; - private RSQLFilterDialect dialect; + private final EntityDictionary dictionary; + private final RSQLFilterDialect dialect; public FilterTranslatorTest() { dictionary = EntityDictionary.builder().build(); dictionary.bindEntity(Book.class); dictionary.bindEntity(Author.class); - dialect = new RSQLFilterDialect(dictionary); + dialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); } @Test @@ -74,7 +77,7 @@ public void testNestedComplexAttributeAlias() throws Exception { @Test public void testHQLQueryVisitor() throws Exception { - List p0Path = Arrays.asList( + List p0Path = List.of( new Path.PathElement(Book.class, Author.class, "authors") ); FilterPredicate p0 = new NotEmptyPredicate(new Path(p0Path)); @@ -85,12 +88,12 @@ public void testHQLQueryVisitor() throws Exception { ); FilterPredicate p1 = new InPredicate(new Path(p1Path), "foo", "bar"); - List p2Path = Arrays.asList( + List p2Path = List.of( new Path.PathElement(Book.class, String.class, "name") ); FilterPredicate p2 = new InPredicate(new Path(p2Path), "blah"); - List p3Path = Arrays.asList( + List p3Path = List.of( new Path.PathElement(Book.class, String.class, "genre") ); FilterPredicate p3 = new InPredicate(new Path(p3Path), "scifi"); @@ -121,7 +124,7 @@ public void testBetweenOperator() throws Exception { new Path.PathElement(Book.class, Author.class, "authors"), new Path.PathElement(Author.class, Long.class, "id") ); - List publishDate = Arrays.asList( + List publishDate = List.of( new Path.PathElement(Book.class, Long.class, "publishDate") ); FilterPredicate authorPred = new FilterPredicate(new Path(authorId), Operator.BETWEEN, Arrays.asList(1, 15)); @@ -150,17 +153,17 @@ public void testBetweenOperator() throws Exception { // Assert excepetion if parameter length is not 2 assertThrows(IllegalArgumentException.class, () -> filterOp.apply( - new FilterPredicate(new Path(authorId), Operator.BETWEEN, Arrays.asList(3)) + new FilterPredicate(new Path(authorId), Operator.BETWEEN, List.of(3)) )); } @Test public void testMemberOfOperator() throws Exception { - List path = Arrays.asList( + List path = List.of( new Path.PathElement(Book.class, String.class, "awards") ); - FilterPredicate p1 = new FilterPredicate(new Path(path), Operator.HASMEMBER, Arrays.asList("awards1")); - FilterPredicate p2 = new FilterPredicate(new Path(path), Operator.HASNOMEMBER, Arrays.asList("awards2")); + FilterPredicate p1 = new FilterPredicate(new Path(path), Operator.HASMEMBER, List.of("awards1")); + FilterPredicate p2 = new FilterPredicate(new Path(path), Operator.HASNOMEMBER, List.of("awards2")); AndFilterExpression and = new AndFilterExpression(p1, p2); @@ -179,7 +182,15 @@ public void testMemberOfOperator() throws Exception { @Test public void testEmptyFieldOnPrefix() throws Exception { FilterPredicate pred = new FilterPredicate(new Path.PathElement(Book.class, String.class, ""), - Operator.PREFIX_CASE_INSENSITIVE, Arrays.asList("value")); + Operator.PREFIX_CASE_INSENSITIVE, List.of("value")); + FilterTranslator filterOp = new FilterTranslator(dictionary); + assertThrows(InvalidValueException.class, () -> filterOp.apply(pred)); + } + + @Test + public void testEmptyFieldOnNotPrefix() throws Exception { + FilterPredicate pred = new FilterPredicate(new Path.PathElement(Book.class, String.class, ""), + Operator.NOT_PREFIX_CASE_INSENSITIVE, List.of("value")); FilterTranslator filterOp = new FilterTranslator(dictionary); assertThrows(InvalidValueException.class, () -> filterOp.apply(pred)); } @@ -187,7 +198,15 @@ public void testEmptyFieldOnPrefix() throws Exception { @Test public void testEmptyFieldOnInfix() throws Exception { FilterPredicate pred = new FilterPredicate(new Path.PathElement(Book.class, String.class, ""), - Operator.INFIX_CASE_INSENSITIVE, Arrays.asList("value")); + Operator.INFIX_CASE_INSENSITIVE, List.of("value")); + FilterTranslator filterOp = new FilterTranslator(dictionary); + assertThrows(InvalidValueException.class, () -> filterOp.apply(pred)); + } + + @Test + public void testEmptyFieldOnNotInfix() throws Exception { + FilterPredicate pred = new FilterPredicate(new Path.PathElement(Book.class, String.class, ""), + Operator.NOT_INFIX_CASE_INSENSITIVE, List.of("value")); FilterTranslator filterOp = new FilterTranslator(dictionary); assertThrows(InvalidValueException.class, () -> filterOp.apply(pred)); } @@ -195,7 +214,15 @@ public void testEmptyFieldOnInfix() throws Exception { @Test public void testEmptyFieldOnPostfix() throws Exception { FilterPredicate pred = new FilterPredicate(new Path.PathElement(Book.class, String.class, ""), - Operator.POSTFIX_CASE_INSENSITIVE, Arrays.asList("value")); + Operator.POSTFIX_CASE_INSENSITIVE, List.of("value")); + FilterTranslator filterOp = new FilterTranslator(dictionary); + assertThrows(InvalidValueException.class, () -> filterOp.apply(pred)); + } + + @Test + public void testEmptyFieldOnNotPostfix() throws Exception { + FilterPredicate pred = new FilterPredicate(new Path.PathElement(Book.class, String.class, ""), + Operator.NOT_POSTFIX_CASE_INSENSITIVE, List.of("value")); FilterTranslator filterOp = new FilterTranslator(dictionary); assertThrows(InvalidValueException.class, () -> filterOp.apply(pred)); } @@ -209,7 +236,7 @@ public void testCustomPredicate() throws Exception { FilterTranslator.registerJPQLGenerator(Operator.INFIX_CASE_INSENSITIVE, ClassType.of(Author.class), "name", generator); FilterPredicate pred = new FilterPredicate(new Path.PathElement(Author.class, String.class, "name"), - Operator.INFIX_CASE_INSENSITIVE, Arrays.asList("value")); + Operator.INFIX_CASE_INSENSITIVE, List.of("value")); String actual = new FilterTranslator(dictionary).apply(pred); assertEquals("FOO", actual); @@ -228,7 +255,7 @@ public void testCustomGlobalOperator() throws Exception { FilterTranslator.registerJPQLGenerator(Operator.INFIX_CASE_INSENSITIVE, generator); FilterPredicate pred = new FilterPredicate(new Path.PathElement(Author.class, String.class, "name"), - Operator.INFIX_CASE_INSENSITIVE, Arrays.asList("value")); + Operator.INFIX_CASE_INSENSITIVE, List.of("value")); String actual = new FilterTranslator(dictionary).apply(pred); assertEquals("FOO", actual); @@ -247,7 +274,7 @@ public void testCustomLocalOperator() throws Exception { ); FilterPredicate pred = new FilterPredicate(new Path.PathElement(Author.class, String.class, "name"), - Operator.INFIX, Arrays.asList("value")); + Operator.INFIX, List.of("value")); Map overrides = new HashMap<>(); overrides.put(Operator.INFIX, generator); diff --git a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/jpql/filter/HasMemberJPQLGeneratorTest.java b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/jpql/filter/HasMemberJPQLGeneratorTest.java index c6cf9249f1..314f888d12 100644 --- a/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/jpql/filter/HasMemberJPQLGeneratorTest.java +++ b/elide-datastore/elide-datastore-jpql/src/test/java/com/yahoo/elide/datastores/jpql/filter/HasMemberJPQLGeneratorTest.java @@ -33,7 +33,7 @@ public HasMemberJPQLGeneratorTest() { dictionary.bindEntity(Book.class); dictionary.bindEntity(Chapter.class); - dialect = new RSQLFilterDialect(dictionary); + dialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); aliasGenerator = (path) -> getFieldAlias(getPathAlias(path, dictionary), path.lastElement().map(Path.PathElement::getFieldName).orElse(null)); diff --git a/elide-datastore/elide-datastore-multiplex/pom.xml b/elide-datastore/elide-datastore-multiplex/pom.xml index 7b233c703e..8acff6e4a7 100644 --- a/elide-datastore/elide-datastore-multiplex/pom.xml +++ b/elide-datastore/elide-datastore-multiplex/pom.xml @@ -10,7 +10,7 @@ com.yahoo.elide elide-datastore-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT diff --git a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/BridgeableTransaction.java b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/BridgeableTransaction.java deleted file mode 100644 index db84898500..0000000000 --- a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/BridgeableTransaction.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2017, Yahoo Inc. - * Licensed under the Apache License, Version 2.0 - * See LICENSE file in project root for terms. - */ -package com.yahoo.elide.datastores.multiplex; - -import com.yahoo.elide.core.RequestScope; -import com.yahoo.elide.core.filter.expression.FilterExpression; -import com.yahoo.elide.core.request.Pagination; -import com.yahoo.elide.core.request.Sorting; - -import java.io.Serializable; -import java.util.Optional; - -/** - * The bridgeable transaction describes an interface suitable for handling composite models - * across stores. More concretely, consider the following example model: - * - * - * public class User { - * String name; - * MyOtherObject otherObject; - * } - * - * - * If we assume User is managed by a MySQL datastore and the MyOtherObject entity is managed by a - * Redis datastore, the MultiplexManager will use a bridgeable store if applicable. The bridgeable store enables - * the writer to determine how to lookup a cross-store relationship via key construction and transaction selection. - * - * NOTE: that since Elide binds a particular type to a datastore attributes will be looked up - * using the datastore reasonable for managing that entity. - * - * N.B. this interface should be implemented on the relevant store in which a particular object lives. - * That is, considering the example above, the Redis store would implement this interface to bridge - * automagically from MySQL. - */ -public interface BridgeableTransaction { - - /** - * Load a single object from a bridgeable store. - * - * NOTE: The filter expression will be pre-populated with an ID-based lookup from Elide. - * This filter expression is constructed with the id passed in the query URL. - * - * @param muxTx Multiplex transaction - * @param parent Parent object - * @param relationName Relation name on parent to expected entity - * @param filterExpression Filter expression to apply to query - * @param lookupId Id of entity intended to be looked up - * N.B. This value may be null if called through a to-one relationship - * and an explicit id was not provided. In such a case, it is expected that the datastore - * can derive the appropriate id with the other provided information. - * @param scope Request scope - * @return Loaded object from bridgeable store. - */ - Object bridgeableLoadObject(MultiplexTransaction muxTx, - Object parent, - String relationName, - Serializable lookupId, - Optional filterExpression, - RequestScope scope); - - /** - * Load a collection of objects from a bridgeable store. - * - * @param muxTx Multiplex transaction - * @param parent Parent object - * @param relationName Relation name on parent to expected entity - * @param filterExpression Filter expression to apply to query - * @param sorting Sorting method for collection - * @param pagination Pagination for collection - * @param scope Request scope - * @return Loaded iterable of objects from bridgeable store. - */ - Iterable bridgeableLoadObjects(MultiplexTransaction muxTx, - Object parent, - String relationName, - Optional filterExpression, - Optional sorting, - Optional pagination, - RequestScope scope); -} diff --git a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexTransaction.java b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexTransaction.java index fd456c935f..33c724ea50 100644 --- a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexTransaction.java +++ b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexTransaction.java @@ -7,9 +7,9 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; -import com.yahoo.elide.core.dictionary.RelationshipType; import com.yahoo.elide.core.exceptions.InvalidCollectionException; import com.yahoo.elide.core.filter.Operator; import com.yahoo.elide.core.filter.expression.FilterExpression; @@ -25,7 +25,6 @@ import java.io.Serializable; import java.util.Collection; import java.util.LinkedHashMap; -import java.util.Optional; import java.util.Set; /** @@ -60,7 +59,7 @@ public T loadObject(EntityProjection projection, } @Override - public Iterable loadObjects( + public DataStoreIterable loadObjects( EntityProjection projection, RequestScope scope) { return getTransaction(projection.getType()).loadObjects(projection, scope); @@ -142,45 +141,31 @@ protected DataStoreTransaction getRelationTransaction(Object object, String rela } @Override - public R getRelation(DataStoreTransaction tx, - T entity, - Relationship relation, - RequestScope scope) { - - FilterExpression filter = relation.getProjection().getFilterExpression(); - + public DataStoreIterable getToManyRelation( + DataStoreTransaction tx, + T entity, + Relationship relation, + RequestScope scope + ) { DataStoreTransaction relationTx = getRelationTransaction(entity, relation.getName()); Type entityType = EntityDictionary.getType(entity); DataStoreTransaction entityTransaction = getTransaction(entityType); - EntityDictionary dictionary = scope.getDictionary(); - Type relationClass = dictionary.getParameterizedType(entityType, relation.getName()); - String idFieldName = dictionary.getIdFieldName(relationClass); - - // If different transactions, check if bridgeable and try to bridge - if (entityTransaction != relationTx && relationTx instanceof BridgeableTransaction) { - BridgeableTransaction bridgeableTx = (BridgeableTransaction) relationTx; - RelationshipType relationType = dictionary.getRelationshipType(entityType, relation.getName()); - Serializable id = (filter != null) ? extractId(filter, idFieldName, relationClass) : null; - - if (relationType.isToMany()) { - return id == null ? (R) bridgeableTx.bridgeableLoadObjects( - this, entity, relation.getName(), - Optional.ofNullable(filter), - Optional.ofNullable(relation.getProjection().getSorting()), - Optional.ofNullable(relation.getProjection().getPagination()), - scope) - : (R) bridgeableTx.bridgeableLoadObject(this, entity, relation.getName(), - id, Optional.ofNullable(filter), scope); - } - - return (R) bridgeableTx.bridgeableLoadObject(this, entity, relation.getName(), id, - Optional.ofNullable(filter), scope); + return entityTransaction.getToManyRelation(relationTx, entity, relation, scope); + } - } + @Override + public R getToOneRelation( + DataStoreTransaction tx, + T entity, + Relationship relation, + RequestScope scope + ) { + DataStoreTransaction relationTx = getRelationTransaction(entity, relation.getName()); + Type entityType = EntityDictionary.getType(entity); + DataStoreTransaction entityTransaction = getTransaction(entityType); - // Otherwise, rely on existing underlying transaction to call correctly into relationTx - return entityTransaction.getRelation(relationTx, entity, relation, scope); + return entityTransaction.getToOneRelation(relationTx, entity, relation, scope); } @Override @@ -215,24 +200,6 @@ public void setAttribute(T entity, Attribute attribute, RequestScope scope) transaction.setAttribute(entity, attribute, scope); } - @Override - public FeatureSupport supportsFiltering(RequestScope scope, Optional parent, EntityProjection projection) { - Type entityClass = projection.getType(); - return getTransaction(entityClass).supportsFiltering(scope, parent, projection); - } - - @Override - public boolean supportsSorting(RequestScope scope, Optional parent, EntityProjection projection) { - Type entityClass = projection.getType(); - return getTransaction(entityClass).supportsSorting(scope, parent, projection); - } - - @Override - public boolean supportsPagination(RequestScope scope, Optional parent, EntityProjection projection) { - Type entityClass = projection.getType(); - return getTransaction(entityClass).supportsPagination(scope, parent, projection); - } - private Serializable extractId(FilterExpression filterExpression, String idFieldName, Type relationClass) { diff --git a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexWriteTransaction.java b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexWriteTransaction.java index b7f4b31a11..7ee53141f3 100644 --- a/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexWriteTransaction.java +++ b/elide-datastore/elide-datastore-multiplex/src/main/java/com/yahoo/elide/datastores/multiplex/MultiplexWriteTransaction.java @@ -7,6 +7,8 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.HttpStatusException; @@ -113,13 +115,17 @@ public void createObject(T entity, RequestScope scope) { clonedObjects.put(entity, NEWLY_CREATED_OBJECT); } - private Iterable hold(DataStoreTransaction transaction, Iterable list) { + private DataStoreIterable hold(DataStoreTransaction transaction, DataStoreIterable list) { ArrayList newList = new ArrayList<>(); list.forEach(newList::add); for (T object : newList) { hold(transaction, object); } - return newList; + return new DataStoreIterableBuilder(newList) + .paginateInMemory(list.needsInMemoryPagination()) + .filterInMemory(list.needsInMemoryFilter()) + .sortInMemory(list.needsInMemorySort()) + .build(); } /** @@ -174,25 +180,30 @@ public T loadObject(EntityProjection projection, } @Override - public Iterable loadObjects( - EntityProjection projection, - RequestScope scope) { + public DataStoreIterable loadObjects(EntityProjection projection, RequestScope scope) { DataStoreTransaction transaction = getTransaction(projection.getType()); return hold(transaction, transaction.loadObjects(projection, scope)); } @Override - public R getRelation(DataStoreTransaction relationTx, - T entity, - Relationship relationship, - RequestScope scope) { + public DataStoreIterable getToManyRelation(DataStoreTransaction relationTx, + T entity, + Relationship relationship, + RequestScope scope) { DataStoreTransaction transaction = getTransaction(EntityDictionary.getType(entity)); - Object relation = super.getRelation(relationTx, entity, relationship, scope); + DataStoreIterable relation = super.getToManyRelation(relationTx, entity, relationship, scope); - if (relation instanceof Iterable) { - return (R) hold(transaction, (Iterable) relation); - } + return hold(transaction, relation); + } + + @Override + public R getToOneRelation(DataStoreTransaction relationTx, + T entity, + Relationship relationship, + RequestScope scope) { + DataStoreTransaction transaction = getTransaction(EntityDictionary.getType(entity)); + R relation = super.getToOneRelation(relationTx, entity, relationship, scope); - return (R) hold(transaction, relation); + return hold(transaction, relation); } } diff --git a/elide-datastore/elide-datastore-multiplex/src/test/java/com/yahoo/elide/datastores/multiplex/TestDataStore.java b/elide-datastore/elide-datastore-multiplex/src/test/java/com/yahoo/elide/datastores/multiplex/TestDataStore.java index d0d33d8359..d3d47289b0 100644 --- a/elide-datastore/elide-datastore-multiplex/src/test/java/com/yahoo/elide/datastores/multiplex/TestDataStore.java +++ b/elide-datastore/elide-datastore-multiplex/src/test/java/com/yahoo/elide/datastores/multiplex/TestDataStore.java @@ -7,6 +7,7 @@ import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.TransactionException; @@ -70,9 +71,7 @@ public Object loadObject(EntityProjection projection, } @Override - public Iterable loadObjects( - EntityProjection projection, - RequestScope scope) { + public DataStoreIterable loadObjects(EntityProjection projection, RequestScope scope) { throw new TransactionException(null); } diff --git a/elide-datastore/elide-datastore-noop/pom.xml b/elide-datastore/elide-datastore-noop/pom.xml index a933ad20ed..b6317ff9d5 100644 --- a/elide-datastore/elide-datastore-noop/pom.xml +++ b/elide-datastore/elide-datastore-noop/pom.xml @@ -6,7 +6,7 @@ com.yahoo.elide elide-datastore-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT diff --git a/elide-datastore/elide-datastore-noop/src/main/java/com/yahoo/elide/datastores/noop/NoopTransaction.java b/elide-datastore/elide-datastore-noop/src/main/java/com/yahoo/elide/datastores/noop/NoopTransaction.java index 7f6365c33f..7d730f2f6b 100644 --- a/elide-datastore/elide-datastore-noop/src/main/java/com/yahoo/elide/datastores/noop/NoopTransaction.java +++ b/elide-datastore/elide-datastore-noop/src/main/java/com/yahoo/elide/datastores/noop/NoopTransaction.java @@ -7,6 +7,8 @@ import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.request.EntityProjection; import lombok.extern.slf4j.Slf4j; @@ -104,10 +106,11 @@ public T loadObject(EntityProjection projection, * @return a {@link Collections#singletonList} with a new persistent resource with id 1 */ @Override - public Iterable loadObjects(EntityProjection projection, - RequestScope scope) { + public DataStoreIterable loadObjects(EntityProjection projection, + RequestScope scope) { // Default behavior: load object 1 and return as an array - return Collections.singletonList(this.loadObject(projection, 1L, scope)); + return new DataStoreIterableBuilder( + Collections.singletonList(this.loadObject(projection, 1L, scope))).build(); } /** diff --git a/elide-datastore/elide-datastore-search/pom.xml b/elide-datastore/elide-datastore-search/pom.xml index 302b800296..6215f7ca2d 100644 --- a/elide-datastore/elide-datastore-search/pom.xml +++ b/elide-datastore/elide-datastore-search/pom.xml @@ -12,7 +12,7 @@ com.yahoo.elide elide-datastore-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -45,7 +45,7 @@ org.hibernate hibernate-search-orm - 5.11.9.Final + 5.11.10.Final dom4j @@ -67,7 +67,7 @@ com.yahoo.elide elide-integration-tests - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT test-jar test @@ -75,7 +75,7 @@ com.yahoo.elide elide-datastore-jpa - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT test @@ -107,16 +107,6 @@ org.mockito mockito-core test - - - net.bytebuddy - byte-buddy - - - net.bytebuddy - byte-buddy-agent - - diff --git a/elide-datastore/elide-datastore-search/src/main/java/com/yahoo/elide/datastores/search/SearchDataTransaction.java b/elide-datastore/elide-datastore-search/src/main/java/com/yahoo/elide/datastores/search/SearchDataTransaction.java index 645cf01e58..8416b5c071 100644 --- a/elide-datastore/elide-datastore-search/src/main/java/com/yahoo/elide/datastores/search/SearchDataTransaction.java +++ b/elide-datastore/elide-datastore-search/src/main/java/com/yahoo/elide/datastores/search/SearchDataTransaction.java @@ -6,10 +6,10 @@ package com.yahoo.elide.datastores.search; -import static com.yahoo.elide.core.datastore.DataStoreTransaction.FeatureSupport.FULL; -import static com.yahoo.elide.core.datastore.DataStoreTransaction.FeatureSupport.NONE; import com.yahoo.elide.core.Path; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.datastore.wrapped.TransactionWrapper; import com.yahoo.elide.core.dictionary.EntityDictionary; @@ -53,6 +53,12 @@ */ public class SearchDataTransaction extends TransactionWrapper { + private enum FilterSupport { + FULL, + PARTIAL, + NONE + } + private EntityDictionary dictionary; private FullTextEntityManager em; private int minNgram; @@ -70,22 +76,28 @@ public SearchDataTransaction(DataStoreTransaction tx, } @Override - public Iterable loadObjects(EntityProjection projection, - RequestScope requestScope) { + public DataStoreIterable loadObjects(EntityProjection projection, + RequestScope requestScope) { if (projection.getFilterExpression() == null) { return super.loadObjects(projection, requestScope); } - boolean canSearch = (canSearch(projection.getType(), projection.getFilterExpression()) != NONE); + FilterSupport filterSupport = canSearch(projection.getType(), projection.getFilterExpression()); + boolean canSearch = (filterSupport != FilterSupport.NONE); if (mustSort(Optional.ofNullable(projection.getSorting()))) { canSearch = canSearch && canSort(projection.getSorting(), projection.getType()); } if (canSearch) { - return search(projection.getType(), projection.getFilterExpression(), + Iterable result = search(projection.getType(), projection.getFilterExpression(), Optional.ofNullable(projection.getSorting()), Optional.ofNullable(projection.getPagination())); + if (filterSupport == FilterSupport.PARTIAL) { + return new DataStoreIterableBuilder(result).allInMemory().build(); + } else { + return new DataStoreIterableBuilder(result).build(); + } } return super.loadObjects(projection, requestScope); @@ -174,32 +186,17 @@ private Sort buildSort(Sorting sorting, Type entityType) { return context.createSort(); } - @Override - public FeatureSupport supportsFiltering(RequestScope scope, Optional parent, EntityProjection projection) { - Type entityClass = projection.getType(); - FilterExpression expression = projection.getFilterExpression(); - - /* Return the least support among all the predicates */ - FeatureSupport support = canSearch(entityClass, expression); - - if (support == NONE) { - return super.supportsFiltering(scope, parent, projection); - } - - return support; - } - - private DataStoreTransaction.FeatureSupport canSearch(Type entityClass, FilterExpression expression) { + private FilterSupport canSearch(Type entityClass, FilterExpression expression) { /* Collapse the filter expression to a list of leaf predicates */ Collection predicates = expression.accept(new PredicateExtractionVisitor()); /* Return the least support among all the predicates */ - FeatureSupport support = predicates.stream() + FilterSupport support = predicates.stream() .map((predicate) -> canSearch(entityClass, predicate)) - .max(Comparator.comparing(Enum::ordinal)).orElse(NONE); + .max(Comparator.comparing(Enum::ordinal)).orElse(FilterSupport.NONE); - if (support == NONE) { + if (support == FilterSupport.NONE) { return support; } @@ -217,17 +214,17 @@ private DataStoreTransaction.FeatureSupport canSearch(Type entityClass, Filte return support; } - private DataStoreTransaction.FeatureSupport canSearch(Type entityClass, FilterPredicate predicate) { + private FilterSupport canSearch(Type entityClass, FilterPredicate predicate) { boolean isIndexed = fieldIsIndexed(entityClass, predicate); if (!isIndexed) { - return NONE; + return FilterSupport.NONE; } /* We don't support joins to other relationships */ if (predicate.getPath().getPathElements().size() != 1) { - return NONE; + return FilterSupport.NONE; } return operatorSupport(entityClass, predicate); @@ -312,7 +309,7 @@ private boolean fieldIsIndexed(Type entityClass, FilterPredicate predicate) { return indexed; } - private DataStoreTransaction.FeatureSupport operatorSupport(Type entityClass, FilterPredicate predicate) + private FilterSupport operatorSupport(Type entityClass, FilterPredicate predicate) throws HttpStatusException { Operator op = predicate.getOperator(); @@ -321,12 +318,12 @@ private DataStoreTransaction.FeatureSupport operatorSupport(Type entityClass, switch (op) { case INFIX: case INFIX_CASE_INSENSITIVE: - return FULL; + return FilterSupport.FULL; case PREFIX: case PREFIX_CASE_INSENSITIVE: - return FeatureSupport.PARTIAL; + return FilterSupport.PARTIAL; default: - return NONE; + return FilterSupport.NONE; } } } diff --git a/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DataStoreLoadTest.java b/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DataStoreLoadTest.java index f936271bf9..0a8811d737 100644 --- a/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DataStoreLoadTest.java +++ b/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DataStoreLoadTest.java @@ -7,6 +7,7 @@ package com.yahoo.elide.datastores.search; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -32,7 +33,6 @@ import com.google.common.collect.Lists; import org.h2.store.fs.FileUtils; import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -59,7 +59,7 @@ public DataStoreLoadTest() { dictionary = EntityDictionary.builder().build(); dictionary.bindEntity(Item.class); - filterParser = new RSQLFilterDialect(dictionary); + filterParser = RSQLFilterDialect.builder().dictionary(dictionary).build(); DataStore mockStore = mock(DataStore.class); wrappedTransaction = mock(DataStoreTransaction.class); @@ -77,11 +77,6 @@ public DataStoreLoadTest() { CoerceUtil.register(Date.class, new ISO8601DateSerde()); } - @BeforeAll - public void initialize() { - FileUtils.createDirectory("/tmp/lucene"); - } - @AfterAll public void cleanup() { FileUtils.deleteRecursive("/tmp/lucene", false); @@ -105,7 +100,7 @@ public void testEqualityPredicate() throws Exception { .filterExpression(filter) .build(), mockScope); - assertListContains(loaded, Lists.newArrayList()); + assertNull(loaded); /* This query should hit the underlying store */ verify(wrappedTransaction, times(1)).loadObjects(any(), any()); @@ -240,7 +235,7 @@ public void testNonIndexedPredicate() throws Exception { .filterExpression(filter) .build(), mockScope); - assertListContains(loaded, Lists.newArrayList()); + assertNull(loaded); /* This query should hit the underlying store */ verify(wrappedTransaction, times(1)).loadObjects(any(), any()); diff --git a/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DataStoreSupportsFilteringTest.java b/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DataStoreSupportsFilteringTest.java index 32d6f95a78..295ae2eaef 100644 --- a/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DataStoreSupportsFilteringTest.java +++ b/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DataStoreSupportsFilteringTest.java @@ -6,11 +6,11 @@ package com.yahoo.elide.datastores.search; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.verify; @@ -18,6 +18,7 @@ import static org.mockito.internal.verification.VerificationModeFactory.times; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.InvalidValueException; @@ -31,13 +32,11 @@ import com.yahoo.elide.datastores.search.models.Item; import org.h2.store.fs.FileUtils; import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import java.util.Date; -import java.util.Optional; import javax.persistence.EntityManagerFactory; import javax.persistence.Persistence; @@ -53,7 +52,7 @@ public DataStoreSupportsFilteringTest() { EntityDictionary dictionary = EntityDictionary.builder().build(); dictionary.bindEntity(Item.class); - filterParser = new RSQLFilterDialect(dictionary); + filterParser = RSQLFilterDialect.builder().dictionary(dictionary).build(); DataStore mockStore = mock(DataStore.class); wrappedTransaction = mock(DataStoreTransaction.class); @@ -64,18 +63,12 @@ public DataStoreSupportsFilteringTest() { searchStore = new SearchDataStore(mockStore, emf, true, 3, 10); searchStore.populateEntityDictionary(dictionary); - mockScope = mock(RequestScope.class); when(mockScope.getDictionary()).thenReturn(dictionary); CoerceUtil.register(Date.class, new ISO8601DateSerde()); } - @BeforeAll - public void initialize() { - FileUtils.createDirectory("/tmp/lucene"); - } - @AfterAll public void cleanup() { FileUtils.deleteRecursive("/tmp/lucene", false); @@ -98,8 +91,12 @@ public void testIndexedFields() throws Exception { .filterExpression(filter) .build(); - assertEquals(DataStoreTransaction.FeatureSupport.FULL, - testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); + DataStoreIterable loaded = testTransaction.loadObjects(projection, mockScope); + + assertFalse(loaded.needsInMemoryFilter()); + assertFalse(loaded.needsInMemoryPagination()); + assertFalse(loaded.needsInMemorySort()); + verify(wrappedTransaction, times(0)).loadObjects(any(), any()); } @Test @@ -114,8 +111,12 @@ public void testIndexedField() throws Exception { .filterExpression(filter) .build(); - assertEquals(DataStoreTransaction.FeatureSupport.FULL, - testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); + DataStoreIterable loaded = testTransaction.loadObjects(projection, mockScope); + + assertFalse(loaded.needsInMemoryFilter()); + assertFalse(loaded.needsInMemoryPagination()); + assertFalse(loaded.needsInMemorySort()); + verify(wrappedTransaction, times(0)).loadObjects(any(), any()); } @Test @@ -130,9 +131,10 @@ public void testUnindexedField() throws Exception { .filterExpression(filter) .build(); - assertNull(testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); - verify(wrappedTransaction, times(1)) - .supportsFiltering(eq(mockScope), any(), eq(projection)); + DataStoreIterable loaded = testTransaction.loadObjects(projection, mockScope); + //The query was passed to the wrapped transaction which is a mock. + assertNull(loaded); + verify(wrappedTransaction, times(1)).loadObjects(any(), any()); } @Test @@ -146,8 +148,7 @@ public void testNgramTooSmall() throws Exception { .filterExpression(filter) .build(); - assertThrows(InvalidValueException.class, - () -> testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); + assertThrows(InvalidValueException.class, () -> testTransaction.loadObjects(projection, mockScope)); } @Test @@ -161,8 +162,7 @@ public void testNgramTooLarge() throws Exception { .filterExpression(filter) .build(); - assertThrows(InvalidValueException.class, - () -> testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); + assertThrows(InvalidValueException.class, () -> testTransaction.loadObjects(projection, mockScope)); } @Test @@ -176,10 +176,10 @@ public void testLargeNgramForEqualityOperator() throws Exception { .filterExpression(filter) .build(); - assertNull(testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); - - verify(wrappedTransaction, times(1)) - .supportsFiltering(eq(mockScope), any(), eq(projection)); + DataStoreIterable loaded = testTransaction.loadObjects(projection, mockScope); + //The query was passed to the wrapped transaction which is a mock. + assertNull(loaded); + verify(wrappedTransaction, times(1)).loadObjects(any(), any()); } @Test @@ -193,8 +193,11 @@ public void testNgramJustRight() throws Exception { .filterExpression(filter) .build(); - assertEquals(DataStoreTransaction.FeatureSupport.FULL, - testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); + DataStoreIterable loaded = testTransaction.loadObjects(projection, mockScope); + assertFalse(loaded.needsInMemoryFilter()); + assertFalse(loaded.needsInMemoryPagination()); + assertFalse(loaded.needsInMemorySort()); + verify(wrappedTransaction, times(0)).loadObjects(any(), any()); } @Test @@ -208,8 +211,11 @@ public void testInfixOperator() throws Exception { .filterExpression(filter) .build(); - assertEquals(DataStoreTransaction.FeatureSupport.FULL, - testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); + DataStoreIterable loaded = testTransaction.loadObjects(projection, mockScope); + assertFalse(loaded.needsInMemoryFilter()); + assertFalse(loaded.needsInMemoryPagination()); + assertFalse(loaded.needsInMemorySort()); + verify(wrappedTransaction, times(0)).loadObjects(any(), any()); } @Test @@ -223,8 +229,11 @@ public void testPrefixOperator() throws Exception { .filterExpression(filter) .build(); - assertEquals(DataStoreTransaction.FeatureSupport.PARTIAL, - testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); + DataStoreIterable loaded = testTransaction.loadObjects(projection, mockScope); + assertTrue(loaded.needsInMemoryFilter()); + assertTrue(loaded.needsInMemoryPagination()); + assertTrue(loaded.needsInMemorySort()); + verify(wrappedTransaction, times(0)).loadObjects(any(), any()); } @Test @@ -238,8 +247,9 @@ public void testEqualityOperator() throws Exception { .filterExpression(filter) .build(); - assertNull(testTransaction.supportsFiltering(mockScope, Optional.empty(), projection)); - verify(wrappedTransaction, times(1)) - .supportsFiltering(eq(mockScope), any(), eq(projection)); + DataStoreIterable loaded = testTransaction.loadObjects(projection, mockScope); + //The query was passed to the wrapped transaction which is a mock. + assertNull(loaded); + verify(wrappedTransaction, times(1)).loadObjects(any(), any()); } } diff --git a/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DependencyBinder.java b/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DependencyBinder.java index 6f9b41044d..9dbb278bb5 100644 --- a/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DependencyBinder.java +++ b/elide-datastore/elide-datastore-search/src/test/java/com/yahoo/elide/datastores/search/DependencyBinder.java @@ -64,9 +64,9 @@ protected void configure() { .withDefaultMaxPageSize(PaginationImpl.MAX_PAGE_LIMIT) .withDefaultPageSize(PaginationImpl.DEFAULT_PAGE_LIMIT) .withISO8601Dates("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("UTC")) - .withJoinFilterDialect(new RSQLFilterDialect(dictionary)) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) .withJoinFilterDialect(new DefaultFilterDialect(dictionary)) - .withSubqueryFilterDialect(new RSQLFilterDialect(dictionary)) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) .withSubqueryFilterDialect(new DefaultFilterDialect(dictionary)) .build()); diff --git a/elide-datastore/pom.xml b/elide-datastore/pom.xml index d4e278fb7f..888da452b3 100644 --- a/elide-datastore/pom.xml +++ b/elide-datastore/pom.xml @@ -14,7 +14,7 @@ com.yahoo.elide elide-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -59,7 +59,17 @@ com.yahoo.elide elide-core - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT + + + com.yahoo.elide + elide-graphql + 6.1.10-SNAPSHOT + + + com.yahoo.elide + elide-test-helpers + 6.1.10-SNAPSHOT org.hibernate @@ -96,7 +106,7 @@ org.codehaus.mojo build-helper-maven-plugin - 3.2.0 + 3.3.0 add-source-generate-sources diff --git a/elide-graphql/pom.xml b/elide-graphql/pom.xml index 4025ad03ad..4e29a052c6 100644 --- a/elide-graphql/pom.xml +++ b/elide-graphql/pom.xml @@ -11,7 +11,7 @@ com.yahoo.elide elide-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -44,12 +44,12 @@ com.yahoo.elide elide-core - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-test-helpers - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.fasterxml.jackson.core @@ -59,7 +59,30 @@ com.graphql-java graphql-java - 17.2 + 19.2 + + + com.graphql-java + graphql-java-extended-scalars + 19.0 + + + javax.websocket + javax.websocket-api + 1.1 + + + + + com.google.code.gson + gson + 2.9.1 + + + + javax.jms + javax.jms-api + 2.0.1 @@ -75,10 +98,16 @@ test + + org.junit.jupiter + junit-jupiter-params + test + + org.apache.ant ant - 1.10.11 + 1.10.12 test @@ -102,6 +131,11 @@ javax.persistence-api test + + com.apollographql.federation + federation-graphql-java-support + 2.1.1 + org.glassfish.jersey.containers jersey-container-servlet @@ -110,10 +144,9 @@ org.skyscreamer jsonassert - 1.5.0 + 1.5.1 test - diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java index 906db6b9bd..adc2008d15 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/Environment.java @@ -37,11 +37,14 @@ public class Environment { public final GraphQLType parentType; public final GraphQLType outputType; public final Field field; + public final NonEntityDictionary nonEntityDictionary; + + public Environment(DataFetchingEnvironment environment, NonEntityDictionary nonEntityDictionary) { + this.nonEntityDictionary = nonEntityDictionary; - public Environment(DataFetchingEnvironment environment) { Map args = environment.getArguments(); - requestScope = environment.getContext(); + requestScope = environment.getLocalContext(); filters = Optional.ofNullable((String) args.get(ModelBuilder.ARGUMENT_FILTER)); offset = Optional.ofNullable((String) args.get(ModelBuilder.ARGUMENT_AFTER)); diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ExecutionResultDeserializer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ExecutionResultDeserializer.java new file mode 100644 index 0000000000..8fe0b1913b --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ExecutionResultDeserializer.java @@ -0,0 +1,65 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import graphql.GraphQLError; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Deserializes JSON into an Execution Result. + */ +@Slf4j +public class ExecutionResultDeserializer extends StdDeserializer { + + ObjectMapper mapper; + GraphQLErrorDeserializer errorDeserializer; + + public ExecutionResultDeserializer() { + super(ExecutionResult.class); + mapper = new ObjectMapper(); + errorDeserializer = new GraphQLErrorDeserializer(); + } + + @Override + public ExecutionResult deserialize(JsonParser parser, DeserializationContext context) throws IOException { + JsonNode root = parser.getCodec().readTree(parser); + + JsonNode dataNode = root.get("data"); + JsonNode errorsNode = root.get("errors"); + + List errors = null; + + if (errorsNode != null) { + errors = new ArrayList<>(); + Iterator nodeIterator = errorsNode.iterator(); + while (nodeIterator.hasNext()) { + JsonNode errorNode = nodeIterator.next(); + errors.add(errorDeserializer.deserialize(errorNode.traverse(parser.getCodec()), context)); + } + } + + Map data = mapper.convertValue(dataNode, new TypeReference<>() { }); + + return ExecutionResultImpl.newExecutionResult() + .errors(errors) + .data(data).build(); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ExecutionResultSerializer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ExecutionResultSerializer.java index 3557eb6e98..9a1cd63b8c 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ExecutionResultSerializer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ExecutionResultSerializer.java @@ -26,6 +26,10 @@ public class ExecutionResultSerializer extends StdSerializer { private final GraphQLErrorSerializer errorSerializer; + public ExecutionResultSerializer() { + this(new GraphQLErrorSerializer()); + } + public ExecutionResultSerializer(GraphQLErrorSerializer errorSerializer) { super(ExecutionResult.class); this.errorSerializer = errorSerializer; diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLConversionUtils.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLConversionUtils.java index f5851b0264..5c9089f9a3 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLConversionUtils.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLConversionUtils.java @@ -6,6 +6,8 @@ package com.yahoo.elide.graphql; +import static graphql.scalars.ExtendedScalars.GraphQLBigDecimal; +import static graphql.scalars.ExtendedScalars.GraphQLBigInteger; import static graphql.schema.GraphQLArgument.newArgument; import static graphql.schema.GraphQLEnumType.newEnum; import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition; @@ -18,6 +20,7 @@ import com.yahoo.elide.core.utils.coerce.CoerceUtil; import com.yahoo.elide.core.utils.coerce.converters.ElideTypeConverter; import graphql.Scalars; +import graphql.scalars.java.JavaPrimitives; import graphql.schema.DataFetcher; import graphql.schema.GraphQLArgument; import graphql.schema.GraphQLEnumType; @@ -94,17 +97,17 @@ public GraphQLScalarType classToScalarType(Type clazz) { } else if (clazz.equals(ClassType.of(boolean.class)) || clazz.equals(ClassType.of(Boolean.class))) { return Scalars.GraphQLBoolean; } else if (clazz.equals(ClassType.of(long.class)) || clazz.equals(ClassType.of(Long.class))) { - return Scalars.GraphQLInt; + return GraphQLBigInteger; } else if (clazz.equals(ClassType.of(float.class)) || clazz.equals(ClassType.of(Float.class))) { return Scalars.GraphQLFloat; } else if (clazz.equals(ClassType.of(double.class)) || clazz.equals(ClassType.of(Double.class))) { - return Scalars.GraphQLFloat; + return GraphQLBigDecimal; } else if (clazz.equals(ClassType.of(short.class)) || clazz.equals(ClassType.of(Short.class))) { return Scalars.GraphQLInt; } else if (clazz.equals(ClassType.of(String.class)) || clazz.equals(ClassType.of(Object.class))) { return Scalars.GraphQLString; } else if (clazz.equals(ClassType.of(BigDecimal.class))) { - return Scalars.GraphQLFloat; + return JavaPrimitives.GraphQLBigDecimal; } return otherClassToScalarType(clazz); } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLEndpoint.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLEndpoint.java index 327a024bbf..168bfee3d1 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLEndpoint.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLEndpoint.java @@ -44,11 +44,13 @@ public class GraphQLEndpoint { private final Map runners; private final Elide elide; + private final HeaderUtils.HeaderProcessor headerProcessor; @Inject public GraphQLEndpoint(@Named("elide") Elide elide) { log.debug("Started ~~"); this.elide = elide; + this.headerProcessor = elide.getElideSettings().getHeaderProcessor(); this.runners = new HashMap<>(); for (String apiVersion : elide.getElideSettings().getDictionary().getApiVersions()) { runners.put(apiVersion, new QueryRunner(elide, apiVersion)); @@ -71,14 +73,14 @@ public Response post( @Context SecurityContext securityContext, String graphQLDocument) { String apiVersion = HeaderUtils.resolveApiVersion(headers.getRequestHeaders()); - Map> requestHeaders = - HeaderUtils.lowercaseAndRemoveAuthHeaders(headers.getRequestHeaders()); + Map> requestHeaders = headerProcessor.process(headers.getRequestHeaders()); User user = new SecurityContextUser(securityContext); QueryRunner runner = runners.getOrDefault(apiVersion, null); ElideResponse response; if (runner == null) { - response = buildErrorResponse(elide, new InvalidOperationException("Invalid API Version"), false); + response = buildErrorResponse(elide.getMapper().getObjectMapper(), + new InvalidOperationException("Invalid API Version"), false); } else { response = runner.run(getBaseUrlEndpoint(uriInfo), graphQLDocument, user, UUID.randomUUID(), requestHeaders); diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLErrorDeserializer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLErrorDeserializer.java new file mode 100644 index 0000000000..ccc6e50305 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLErrorDeserializer.java @@ -0,0 +1,92 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import static graphql.ErrorType.ExecutionAborted; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import graphql.ErrorClassification; +import graphql.GraphQLError; +import graphql.language.SourceLocation; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Deserializes JSON into a GraphQLError. + */ +public class GraphQLErrorDeserializer extends StdDeserializer { + + /** + * Constructor. + */ + public GraphQLErrorDeserializer() { + super(GraphQLError.class); + } + + @Override + public GraphQLError deserialize(JsonParser parser, DeserializationContext context) throws IOException { + JsonNode root = parser.getCodec().readTree(parser); + + JsonNode messageNode = root.get("message"); + JsonNode pathNode = root.get("path"); + JsonNode sourceLocations = root.get("locations"); + + GraphQLError error = new GraphQLError() { + @Override + public String toString() { + return String.format("{ \"message\": \"%s\", \"locations\": %s, \"path\": %s}", + getMessage(), + getLocations(), + getPath()); + } + + @Override + public String getMessage() { + return messageNode == null ? null : messageNode.textValue(); + } + + @Override + public List getLocations() { + if (sourceLocations != null) { + List result = new ArrayList<>(); + sourceLocations.forEach(sourceLocation -> { + result.add(new SourceLocation( + sourceLocation.get("line").asInt(), + sourceLocation.get("column").asInt() + )); + }); + + return result; + } + return null; + } + + @Override + public ErrorClassification getErrorType() { + return ExecutionAborted; + } + + @Override + public List getPath() { + if (pathNode != null) { + List paths = new ArrayList<>(); + pathNode.forEach(path -> { + paths.add(path.asText()); + }); + return paths; + } + return null; + } + }; + + return error; + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLNameUtils.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLNameUtils.java index 298d926dac..8b1c792704 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLNameUtils.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLNameUtils.java @@ -8,7 +8,6 @@ import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.type.Type; -import com.yahoo.elide.graphql.subscriptions.Subscription; import org.apache.commons.lang3.StringUtils; public class GraphQLNameUtils { @@ -63,29 +62,14 @@ public String toConnectionName(Type clazz) { return toOutputTypeName(clazz) + CONNECTION_SUFFIX; } - public String toSubscriptionName(Type clazz, Subscription.Operation operation) { - String suffix; - switch (operation) { - case CREATE: { - suffix = "Added"; - break; - } - case DELETE: { - suffix = "Deleted"; - break; - } - default : { - suffix = "Updated"; - break; - } - } - return StringUtils.uncapitalize(toOutputTypeName(clazz) + suffix); - } - public String toNonElideOutputTypeName(Type clazz) { return StringUtils.uncapitalize(toOutputTypeName(clazz)); } + public String toTopicName(Type clazz) { + return toOutputTypeName(clazz) + "Topic"; + } + public String toNonElideInputTypeName(Type clazz) { return StringUtils.uncapitalize(toInputTypeName(clazz)); } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLRequestScope.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLRequestScope.java index f22d385e6c..25dc555a3c 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLRequestScope.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/GraphQLRequestScope.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java index 298de721a8..8bb008db15 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/ModelBuilder.java @@ -10,10 +10,13 @@ import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition; import static graphql.schema.GraphQLInputObjectField.newInputObjectField; import static graphql.schema.GraphQLObjectType.newObject; + +import com.yahoo.elide.ElideSettings; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.dictionary.RelationshipType; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; +import com.apollographql.federation.graphqljava.Federation; import org.apache.commons.collections4.CollectionUtils; import graphql.Scalars; import graphql.schema.DataFetcher; @@ -72,6 +75,8 @@ public class ModelBuilder { private Set> excludedEntities; private Set objectTypes; + private boolean enableFederation; + /** * Class constructor, constructs the custom arguments to handle mutations. * @param entityDictionary elide entity dictionary @@ -80,6 +85,7 @@ public class ModelBuilder { */ public ModelBuilder(EntityDictionary entityDictionary, NonEntityDictionary nonEntityDictionary, + ElideSettings settings, DataFetcher dataFetcher, String apiVersion) { objectTypes = new HashSet<>(); this.generator = new GraphQLConversionUtils(entityDictionary, nonEntityDictionary); @@ -88,6 +94,7 @@ public ModelBuilder(EntityDictionary entityDictionary, this.nameUtils = new GraphQLNameUtils(entityDictionary); this.dataFetcher = dataFetcher; this.apiVersion = apiVersion; + this.enableFederation = settings.isEnableGraphQLFederation(); relationshipOpArg = newArgument() .name(ARGUMENT_OPERATION) @@ -218,6 +225,8 @@ public GraphQLSchema build() { inputObjectRegistry.values()))) .build(); + //Enable Apollo Federation + schema = (enableFederation) ? Federation.transform(schema).build() : schema; return schema; } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/PersistentResourceFetcher.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/PersistentResourceFetcher.java index c767d714dc..8fe0f900f2 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/PersistentResourceFetcher.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/PersistentResourceFetcher.java @@ -7,6 +7,7 @@ package com.yahoo.elide.graphql; import static com.yahoo.elide.graphql.ModelBuilder.ARGUMENT_OPERATION; +import static com.yahoo.elide.graphql.RelationshipOp.FETCH; import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.RequestScope; import com.yahoo.elide.core.dictionary.EntityDictionary; @@ -18,18 +19,13 @@ import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; import com.yahoo.elide.graphql.containers.ConnectionContainer; +import com.yahoo.elide.graphql.containers.GraphQLContainer; import com.yahoo.elide.graphql.containers.MapEntryContainer; import com.google.common.collect.Sets; -import org.apache.commons.collections4.CollectionUtils; -import graphql.language.Field; -import graphql.language.FragmentSpread; import graphql.language.OperationDefinition; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; -import graphql.schema.GraphQLNamedType; -import graphql.schema.GraphQLType; import io.reactivex.Observable; -import lombok.Getter; import lombok.extern.slf4j.Slf4j; import java.util.ArrayDeque; @@ -49,8 +45,7 @@ * Invoked by GraphQL Java to fetch/mutate data from Elide. */ @Slf4j -public class PersistentResourceFetcher implements DataFetcher { - @Getter +public class PersistentResourceFetcher implements DataFetcher, QueryLogger { private final NonEntityDictionary nonEntityDictionary; public PersistentResourceFetcher(NonEntityDictionary nonEntityDictionary) { @@ -69,17 +64,17 @@ public Object get(DataFetchingEnvironment environment) { Map args = environment.getArguments(); /* fetch current operation */ - RelationshipOp operation = (RelationshipOp) args.getOrDefault(ARGUMENT_OPERATION, RelationshipOp.FETCH); + RelationshipOp operation = (RelationshipOp) args.getOrDefault(ARGUMENT_OPERATION, FETCH); /* build environment object, extracts required fields */ - Environment context = new Environment(environment); + Environment context = new Environment(environment, nonEntityDictionary); /* safe enable debugging */ if (log.isDebugEnabled()) { - logContext(operation, context); + logContext(log, operation, context); } - if (operation != RelationshipOp.FETCH) { + if (operation != FETCH) { /* Don't allow write operations in a non-mutation request. */ if (environment.getOperationDefinition().getOperation() != OperationDefinition.Operation.MUTATION) { throw new BadRequestException("Data model writes are only allowed in mutations"); @@ -88,29 +83,44 @@ public Object get(DataFetchingEnvironment environment) { filterSortPaginateSanityCheck(context); } + GraphQLContainer container; + /* delegate request */ switch (operation) { - case FETCH: + case FETCH: { return fetchObjects(context); - - case UPSERT: - return upsertObjects(context); - - case UPDATE: - return updateObjects(context); - - case DELETE: - return deleteObjects(context); - - case REMOVE: - return removeObjects(context); - - case REPLACE: - return replaceObjects(context); + } + case UPSERT: { + container = upsertObjects(context); + break; + } + case UPDATE: { + container = updateObjects(context); + break; + } + case DELETE: { + container = deleteObjects(context); + break; + } + case REMOVE: { + container = removeObjects(context); + break; + } + case REPLACE: { + container = replaceObjects(context); + break; + } default: throw new UnsupportedOperationException("Unknown operation: " + operation); } + + if (operation != FETCH) { + context.requestScope.runQueuedPreSecurityTriggers(); + context.requestScope.runQueuedPreFlushTriggers(); + } + + return container; } /** @@ -124,41 +134,6 @@ private void filterSortPaginateSanityCheck(Environment environment) { } } - /** - * log current context for debugging. - * @param operation Current operation - * @param environment Environment encapsulating graphQL's request environment - */ - private void logContext(RelationshipOp operation, Environment environment) { - List children = (environment.field.getSelectionSet() != null) - ? (List) environment.field.getSelectionSet().getChildren() - : new ArrayList<>(); - List fieldName = new ArrayList<>(); - if (CollectionUtils.isNotEmpty(children)) { - children.stream().forEach(i -> { - if (i.getClass().equals(Field.class)) { - fieldName.add(((Field) i).getName()); - } else if (i.getClass().equals(FragmentSpread.class)) { - fieldName.add(((FragmentSpread) i).getName()); - } else { - log.debug("A new type of Selection, other than Field and FragmentSpread was encountered, {}", - i.getClass()); - } - }); - } - - String requestedFields = environment.field.getName() + fieldName; - - GraphQLType parent = environment.parentType; - if (log.isDebugEnabled()) { - String typeName = (parent instanceof GraphQLNamedType) - ? ((GraphQLNamedType) parent).getName() - : parent.toString(); - log.debug("{} {} fields with parent {}<{}>", operation, requestedFields, - EntityDictionary.getSimpleName(EntityDictionary.getType(parent)), typeName); - } - } - /** * handle FETCH operation. * @param context Environment encapsulating graphQL's request environment @@ -171,7 +146,7 @@ private Object fetchObjects(Environment context) { } // Process fetch object for this container - return context.container.processFetch(context, this); + return context.container.processFetch(context); } /** @@ -181,7 +156,7 @@ private Object fetchObjects(Environment context) { * @param ids List of ids (can be NULL) * @return {@link PersistentResource} object(s) */ - public ConnectionContainer fetchObject( + public static ConnectionContainer fetchObject( RequestScope requestScope, EntityProjection projection, Optional> ids @@ -211,7 +186,7 @@ public ConnectionContainer fetchObject( * @param ids List of ids * @return persistence resource object(s) */ - public Object fetchRelationship( + public static ConnectionContainer fetchRelationship( PersistentResource parentResource, @NotNull Relationship relationship, Optional> ids @@ -491,7 +466,7 @@ private PersistentResource updateAttributes(PersistentResource toUpdate, * @param context Environment encapsulating graphQL's request environment * @return set of deleted {@link PersistentResource} object(s) */ - private Object deleteObjects(Environment context) { + private ConnectionContainer deleteObjects(Environment context) { /* sanity check for id and data argument w DELETE */ if (context.data.isPresent()) { throw new BadRequestException("DELETE must not include data argument"); @@ -517,7 +492,7 @@ private Object deleteObjects(Environment context) { * @param context Environment encapsulating graphQL's request environment * @return set of removed {@link PersistentResource} object(s) */ - private Object removeObjects(Environment context) { + private ConnectionContainer removeObjects(Environment context) { /* sanity check for id and data argument w REPLACE */ if (context.data.isPresent()) { throw new BadRequestException("REPLACE must not include data argument"); @@ -560,7 +535,7 @@ private ConnectionContainer replaceObjects(Environment context) { } ConnectionContainer existingObjects = - (ConnectionContainer) context.container.processFetch(context, this); + (ConnectionContainer) context.container.processFetch(context); ConnectionContainer upsertedObjects = upsertObjects(context); Set toDelete = Sets.difference(existingObjects.getPersistentResources(), upsertedObjects.getPersistentResources()); diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryLogger.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryLogger.java new file mode 100644 index 0000000000..f3c7f3b3ea --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryLogger.java @@ -0,0 +1,58 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import com.yahoo.elide.core.dictionary.EntityDictionary; +import org.apache.commons.collections4.CollectionUtils; +import org.slf4j.Logger; +import graphql.language.Field; +import graphql.language.FragmentSpread; +import graphql.schema.GraphQLNamedType; +import graphql.schema.GraphQLType; + +import java.util.ArrayList; +import java.util.List; + +/** + * Logs an incoming GraphQL query. + */ +public interface QueryLogger { + /** + * log current context for debugging. + * @param operation Current operation + * @param environment Environment encapsulating graphQL's request environment + */ + default void logContext(Logger log, RelationshipOp operation, Environment environment) { + List children = (environment.field.getSelectionSet() != null) + ? (List) environment.field.getSelectionSet().getChildren() + : new ArrayList<>(); + List fieldName = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(children)) { + children.stream().forEach(i -> { + if (i.getClass().equals(Field.class)) { + fieldName.add(((Field) i).getName()); + } else if (i.getClass().equals(FragmentSpread.class)) { + fieldName.add(((FragmentSpread) i).getName()); + } else { + log.debug("A new type of Selection, other than Field and FragmentSpread was encountered, {}", + i.getClass()); + } + }); + } + + String requestedFields = environment.field.getName() + fieldName; + + GraphQLType parent = environment.parentType; + if (log.isDebugEnabled()) { + String typeName = (parent instanceof GraphQLNamedType) + ? ((GraphQLNamedType) parent).getName() + : parent.toString(); + log.debug("{} {} fields with parent {}<{}>", operation, requestedFields, + EntityDictionary.getSimpleName(EntityDictionary.getType(parent)), typeName); + } + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunner.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunner.java index e8d5f9ba08..37da10d026 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunner.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunner.java @@ -19,6 +19,8 @@ import com.yahoo.elide.core.security.User; import com.yahoo.elide.graphql.parser.GraphQLEntityProjectionMaker; import com.yahoo.elide.graphql.parser.GraphQLProjectionInfo; +import com.yahoo.elide.graphql.parser.GraphQLQuery; +import com.yahoo.elide.graphql.parser.QueryParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.Version; import com.fasterxml.jackson.databind.JsonNode; @@ -33,17 +35,15 @@ import graphql.GraphQL; import graphql.GraphQLError; import graphql.execution.AsyncSerialExecutionStrategy; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.function.Function; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; import javax.validation.ConstraintViolation; import javax.validation.ConstraintViolationException; import javax.ws.rs.WebApplicationException; @@ -54,8 +54,13 @@ */ @Slf4j public class QueryRunner { + + @Getter private final Elide elide; private GraphQL api; + private ObjectMapper mapper; + + @Getter private String apiVersion; private static final String QUERY = "query"; @@ -70,6 +75,7 @@ public class QueryRunner { public QueryRunner(Elide elide, String apiVersion) { this.elide = elide; this.apiVersion = apiVersion; + this.mapper = elide.getMapper().getObjectMapper(); EntityDictionary dictionary = elide.getElideSettings().getDictionary(); @@ -79,7 +85,7 @@ public QueryRunner(Elide elide, String apiVersion) { PersistentResourceFetcher fetcher = new PersistentResourceFetcher(nonEntityDictionary); ModelBuilder builder = new ModelBuilder(elide.getElideSettings().getDictionary(), - nonEntityDictionary, fetcher, apiVersion); + nonEntityDictionary, elide.getElideSettings(), fetcher, apiVersion); api = GraphQL.newGraphQL(builder.build()) .queryExecutionStrategy(new AsyncSerialExecutionStrategy()) @@ -110,7 +116,26 @@ public ElideResponse run(String baseUrlEndPoint, String graphQLDocument, User us * @return is a mutation. */ public static boolean isMutation(String query) { - return query != null && query.trim().startsWith(MUTATION); + if (query == null) { + return false; + } + + String[] lines = query.split("\n"); + + StringBuilder withoutComments = new StringBuilder(); + + for (String line : lines) { + //Remove GraphiQL comment lines.... + if (line.matches("^(\\s*)#.*")) { + continue; + } + withoutComments.append(line); + withoutComments.append("\n"); + } + + query = withoutComments.toString().trim(); + + return query.startsWith(MUTATION); } /** @@ -146,55 +171,54 @@ public ElideResponse run(String baseUrlEndPoint, String graphQLDocument, User us Map> requestHeaders) { ObjectMapper mapper = elide.getMapper().getObjectMapper(); - JsonNode topLevel; - + List queries; try { - topLevel = getTopLevelNode(mapper, graphQLDocument); + queries = new QueryParser() { + }.parseDocument(graphQLDocument, mapper); } catch (IOException e) { log.debug("Invalid json body provided to GraphQL", e); // NOTE: Can't get at isVerbose setting here for hardcoding to false. If necessary, we can refactor // so this can be set appropriately. - return buildErrorResponse(elide, new InvalidEntityBodyException(graphQLDocument), false); + return buildErrorResponse(mapper, new InvalidEntityBodyException(graphQLDocument), false); } - Function executeRequest = - (node) -> executeGraphQLRequest(baseUrlEndPoint, mapper, user, graphQLDocument, node, requestId, - requestHeaders); - - if (topLevel.isArray()) { - Iterator nodeIterator = topLevel.iterator(); - Iterable nodeIterable = () -> nodeIterator; - // NOTE: Create a non-parallel stream - // It's unclear whether or not the expectations of the caller would be that requests are intended - // to run serially even outside of a single transaction. We should revisit this. - Stream nodeStream = StreamSupport.stream(nodeIterable.spliterator(), false); - ArrayNode result = nodeStream - .map(executeRequest) - .map(response -> { - try { - return mapper.readTree(response.getBody()); - } catch (IOException e) { - log.debug("Caught an IO exception while trying to read response body"); - return JsonNodeFactory.instance.objectNode(); - } - }) - .reduce(JsonNodeFactory.instance.arrayNode(), - (arrayNode, node) -> arrayNode.add(node), - (left, right) -> left.addAll(right)); - try { - return ElideResponse.builder() - .responseCode(HttpStatus.SC_OK) - .body(mapper.writeValueAsString(result)) - .build(); - } catch (IOException e) { - log.error("An unexpected error occurred trying to serialize array response.", e); - return ElideResponse.builder() - .responseCode(HttpStatus.SC_INTERNAL_SERVER_ERROR) - .build(); - } + List responses = new ArrayList<>(); + for (GraphQLQuery query : queries) { + responses.add(executeGraphQLRequest(baseUrlEndPoint, mapper, user, + graphQLDocument, query, requestId, requestHeaders)); + } + + if (responses.size() == 1) { + return responses.get(0); } - return executeRequest.apply(topLevel); + //Convert the list of responses into a single JSON Array. + ArrayNode result = responses.stream() + .map(response -> { + try { + return mapper.readTree(response.getBody()); + } catch (IOException e) { + log.debug("Caught an IO exception while trying to read response body"); + return JsonNodeFactory.instance.objectNode(); + } + }) + .reduce(JsonNodeFactory.instance.arrayNode(), + (arrayNode, node) -> arrayNode.add(node), + (left, right) -> left.addAll(right)); + + try { + + //Build and elide response from the array of responses. + return ElideResponse.builder() + .responseCode(HttpStatus.SC_OK) + .body(mapper.writeValueAsString(result)) + .build(); + } catch (IOException e) { + log.error("An unexpected error occurred trying to serialize array response.", e); + return ElideResponse.builder() + .responseCode(HttpStatus.SC_INTERNAL_SERVER_ERROR) + .build(); + } } /** @@ -236,23 +260,28 @@ public static String extractOperation(JsonNode jsonDocument) { } private ElideResponse executeGraphQLRequest(String baseUrlEndPoint, ObjectMapper mapper, User principal, - String graphQLDocument, JsonNode jsonDocument, UUID requestId, + String graphQLDocument, GraphQLQuery query, UUID requestId, Map> requestHeaders) { boolean isVerbose = false; - try (DataStoreTransaction tx = elide.getDataStore().beginTransaction()) { + String queryText = query.getQuery(); + boolean isMutation = isMutation(queryText); + + try (DataStoreTransaction tx = isMutation + ? elide.getDataStore().beginTransaction() + : elide.getDataStore().beginReadTransaction()) { + elide.getTransactionRegistry().addRunningTransaction(requestId, tx); - if (!jsonDocument.has(QUERY)) { + if (query.getQuery() == null || query.getQuery().isEmpty()) { return ElideResponse.builder().responseCode(HttpStatus.SC_BAD_REQUEST) .body("A `query` key is required.").build(); } - String query = extractQuery(jsonDocument); // get variables from request for constructing entityProjections - Map variables = extractVariables(mapper, jsonDocument); + Map variables = query.getVariables(); //TODO - get API version. GraphQLProjectionInfo projectionInfo = new GraphQLEntityProjectionMaker(elide.getElideSettings(), variables, - apiVersion).make(query); + apiVersion).make(queryText); GraphQLRequestScope requestScope = new GraphQLRequestScope(baseUrlEndPoint, tx, principal, apiVersion, elide.getElideSettings(), projectionInfo, requestId, requestHeaders); @@ -260,23 +289,22 @@ private ElideResponse executeGraphQLRequest(String baseUrlEndPoint, ObjectMapper // Logging all queries. It is recommended to put any private information that shouldn't be logged into // the "variables" section of your query. Variable values are not logged. - log.info("Processing GraphQL query:\n{}", query); - - ExecutionInput.Builder executionInput = new ExecutionInput.Builder().context(requestScope).query(query); + log.info("Processing GraphQL query:\n{}", queryText); - String operationName = extractOperation(jsonDocument); + ExecutionInput.Builder executionInput = new ExecutionInput.Builder() + .localContext(requestScope) + .query(queryText); - if (operationName != null) { - executionInput.operationName(operationName); + if (query.getOperationName() != null) { + executionInput.operationName(query.getOperationName()); } executionInput.variables(variables); ExecutionResult result = api.execute(executionInput); tx.preCommit(requestScope); - requestScope.runQueuedPreSecurityTriggers(); requestScope.getPermissionExecutor().executeCommitChecks(); - if (isMutation(query)) { + if (isMutation) { if (!result.getErrors().isEmpty()) { HashMap abortedResponseObject = new HashMap<>(); abortedResponseObject.put("errors", result.getErrors()); @@ -287,7 +315,7 @@ private ElideResponse executeGraphQLRequest(String baseUrlEndPoint, ObjectMapper } requestScope.saveOrCreateObjects(); } - requestScope.runQueuedPreFlushTriggers(); + tx.flush(requestScope); requestScope.runQueuedPreCommitTriggers(); @@ -301,25 +329,49 @@ private ElideResponse executeGraphQLRequest(String baseUrlEndPoint, ObjectMapper return ElideResponse.builder().responseCode(HttpStatus.SC_OK).body(mapper.writeValueAsString(result)) .build(); - } catch (JsonProcessingException e) { - log.debug("Invalid json body provided to GraphQL", e); - return buildErrorResponse(elide, new InvalidEntityBodyException(graphQLDocument), isVerbose); } catch (IOException e) { - log.error("Uncaught IO Exception by Elide in GraphQL", e); - return buildErrorResponse(elide, new TransactionException(e), isVerbose); + return handleNonRuntimeException(elide, e, graphQLDocument, isVerbose); } catch (RuntimeException e) { - return handleRuntimeException(e, isVerbose); + return handleRuntimeException(elide, e, isVerbose); } finally { elide.getTransactionRegistry().removeRunningTransaction(requestId); elide.getAuditLogger().clear(); } } - private ElideResponse handleRuntimeException(RuntimeException error, boolean isVerbose) { + public static ElideResponse handleNonRuntimeException( + Elide elide, + Exception error, + String graphQLDocument, + boolean isVerbose + ) { CustomErrorException mappedException = elide.mapError(error); + ObjectMapper mapper = elide.getMapper().getObjectMapper(); if (mappedException != null) { - return buildErrorResponse(elide, mappedException, isVerbose); + return buildErrorResponse(mapper, mappedException, isVerbose); + } + + if (error instanceof JsonProcessingException) { + log.debug("Invalid json body provided to GraphQL", error); + return buildErrorResponse(mapper, new InvalidEntityBodyException(graphQLDocument), isVerbose); + } + + if (error instanceof IOException) { + log.error("Uncaught IO Exception by Elide in GraphQL", error); + return buildErrorResponse(mapper, new TransactionException(error), isVerbose); + } + + log.error("Error or exception uncaught by Elide", error); + throw new RuntimeException(error); + } + + public static ElideResponse handleRuntimeException(Elide elide, RuntimeException error, boolean isVerbose) { + CustomErrorException mappedException = elide.mapError(error); + ObjectMapper mapper = elide.getMapper().getObjectMapper(); + + if (mappedException != null) { + return buildErrorResponse(mapper, mappedException, isVerbose); } if (error instanceof WebApplicationException) { @@ -340,7 +392,7 @@ private ElideResponse handleRuntimeException(RuntimeException error, boolean isV log.debug("Caught HTTP status exception {}", e.getStatus(), e); } - return buildErrorResponse(elide, new HttpStatusException(200, e.getMessage()) { + return buildErrorResponse(mapper, new HttpStatusException(200, e.getMessage()) { @Override public int getStatus() { return 200; @@ -384,7 +436,7 @@ public String toString() { } } return buildErrorResponse( - elide, + mapper, new CustomErrorException(HttpStatus.SC_OK, message, errorObjectsBuilder.build()), isVerbose ); @@ -394,8 +446,7 @@ public String toString() { throw new RuntimeException(error); } - public static ElideResponse buildErrorResponse(Elide elide, HttpStatusException error, boolean isVerbose) { - ObjectMapper mapper = elide.getMapper().getObjectMapper(); + public static ElideResponse buildErrorResponse(ObjectMapper mapper, HttpStatusException error, boolean isVerbose) { JsonNode errorNode; if (!(error instanceof CustomErrorException)) { // get the error message and optionally encode it diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunners.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunners.java new file mode 100644 index 0000000000..cf978253e4 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/QueryRunners.java @@ -0,0 +1,44 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.RefreshableElide; + +import java.util.HashMap; +import java.util.Map; + +/** + * Maps API version to a GraphQL query runner. This class is hot reloadable and must be restricted to a single + * access method. + */ +public class QueryRunners { + private final Map runners; + + /** + * Constructor. + * @param refreshableElide A hot reloadable Elide instance. + */ + public QueryRunners(RefreshableElide refreshableElide) { + this.runners = new HashMap<>(); + Elide elide = refreshableElide.getElide(); + + for (String apiVersion : elide.getElideSettings().getDictionary().getApiVersions()) { + runners.put(apiVersion, new QueryRunner(elide, apiVersion)); + } + } + + /** + * Gets a runner for a given API version. This is the ONLY access method for this class to + * eliminate state issues across reloads. + * @param apiVersion The api version. + * @return The associated query runner. + */ + public QueryRunner getRunner(String apiVersion) { + return runners.get(apiVersion); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/ConnectionContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/ConnectionContainer.java index 7ab6fe39ca..6510eda701 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/ConnectionContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/ConnectionContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -10,7 +10,6 @@ import com.yahoo.elide.core.request.Pagination; import com.yahoo.elide.graphql.Environment; import com.yahoo.elide.graphql.KeyWord; -import com.yahoo.elide.graphql.PersistentResourceFetcher; import lombok.AllArgsConstructor; import lombok.Getter; @@ -29,7 +28,7 @@ public class ConnectionContainer implements GraphQLContainer { @Getter private final String typeName; @Override - public Object processFetch(Environment context, PersistentResourceFetcher fetcher) { + public Object processFetch(Environment context) { String fieldName = context.field.getName(); switch (KeyWord.byName(fieldName)) { diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/EdgesContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/EdgesContainer.java index 60fc3f832b..e8d1f2672a 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/EdgesContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/EdgesContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -9,7 +9,6 @@ import com.yahoo.elide.core.PersistentResource; import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.graphql.Environment; -import com.yahoo.elide.graphql.PersistentResourceFetcher; import lombok.AllArgsConstructor; import lombok.Getter; @@ -17,11 +16,11 @@ * Container for edges. */ @AllArgsConstructor -public class EdgesContainer implements PersistentResourceContainer, GraphQLContainer { +public class EdgesContainer implements PersistentResourceContainer, GraphQLContainer { @Getter private final PersistentResource persistentResource; @Override - public Object processFetch(Environment context, PersistentResourceFetcher fetcher) { + public NodeContainer processFetch(Environment context) { String fieldName = context.field.getName(); // TODO: Cursor diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/GraphQLContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/GraphQLContainer.java index 0bced3915d..e627313f1f 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/GraphQLContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/GraphQLContainer.java @@ -1,16 +1,16 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.graphql.containers; import com.yahoo.elide.graphql.Environment; -import com.yahoo.elide.graphql.PersistentResourceFetcher; /** * Interface describing how to process GraphQL request at each step. + * @param The type returned by the container. */ -public interface GraphQLContainer { - Object processFetch(Environment context, PersistentResourceFetcher fetcher); +public interface GraphQLContainer { + T processFetch(Environment context); } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/MapEntryContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/MapEntryContainer.java index eefac0b420..b023fe7328 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/MapEntryContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/MapEntryContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -10,7 +10,6 @@ import com.yahoo.elide.graphql.Entity; import com.yahoo.elide.graphql.Environment; import com.yahoo.elide.graphql.NonEntityDictionary; -import com.yahoo.elide.graphql.PersistentResourceFetcher; import java.util.Collection; import java.util.HashMap; @@ -35,8 +34,8 @@ public MapEntryContainer(Map.Entry entry) { } @Override - public Object processFetch(Environment context, PersistentResourceFetcher fetcher) { - NonEntityDictionary nonEntityDictionary = fetcher.getNonEntityDictionary(); + public Object processFetch(Environment context) { + NonEntityDictionary nonEntityDictionary = context.nonEntityDictionary; String fieldName = context.field.getName(); Object returnObject; diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/NodeContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/NodeContainer.java index 3c837251e3..adc4952adf 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/NodeContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/NodeContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -28,12 +28,12 @@ */ @AllArgsConstructor public class NodeContainer implements PersistentResourceContainer, GraphQLContainer { - @Getter private final PersistentResource persistentResource; + @Getter protected final PersistentResource persistentResource; @Override - public Object processFetch(Environment context, PersistentResourceFetcher fetcher) { + public Object processFetch(Environment context) { EntityDictionary entityDictionary = context.requestScope.getDictionary(); - NonEntityDictionary nonEntityDictionary = fetcher.getNonEntityDictionary(); + NonEntityDictionary nonEntityDictionary = context.nonEntityDictionary; Type parentClass = context.parentResource.getResourceType(); String fieldName = context.field.getName(); @@ -78,7 +78,7 @@ public Object processFetch(Environment context, PersistentResourceFetcher fetche + context.parentResource.getTypeName() + "." + fieldName); } - return fetcher.fetchRelationship(context.parentResource, relationship, context.ids); + return fetchRelationship(context, relationship); } if (Objects.equals(idFieldName, fieldName)) { return new DeferredId(context.parentResource); @@ -86,4 +86,8 @@ public Object processFetch(Environment context, PersistentResourceFetcher fetche throw new BadRequestException("Unrecognized object: " + fieldName + " for: " + parentClass.getName() + " in node"); } + + protected Object fetchRelationship(Environment context, Relationship relationship) { + return PersistentResourceFetcher.fetchRelationship(context.parentResource, relationship, context.ids); + } } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/NonEntityContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/NonEntityContainer.java index ae65722b3d..43d61875f6 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/NonEntityContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/NonEntityContainer.java @@ -9,7 +9,6 @@ import com.yahoo.elide.core.type.Type; import com.yahoo.elide.graphql.Environment; import com.yahoo.elide.graphql.NonEntityDictionary; -import com.yahoo.elide.graphql.PersistentResourceFetcher; import lombok.AllArgsConstructor; import lombok.Getter; @@ -25,8 +24,8 @@ public class NonEntityContainer implements GraphQLContainer { @Getter private final Object nonEntity; @Override - public Object processFetch(Environment context, PersistentResourceFetcher fetcher) { - NonEntityDictionary nonEntityDictionary = fetcher.getNonEntityDictionary(); + public Object processFetch(Environment context) { + NonEntityDictionary nonEntityDictionary = context.nonEntityDictionary; String fieldName = context.field.getName(); diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PageInfoContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PageInfoContainer.java index e6ca28ccb1..36b7f804ad 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PageInfoContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PageInfoContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -10,7 +10,6 @@ import com.yahoo.elide.core.request.Pagination; import com.yahoo.elide.graphql.Environment; import com.yahoo.elide.graphql.KeyWord; -import com.yahoo.elide.graphql.PersistentResourceFetcher; import lombok.Getter; import java.util.List; @@ -28,7 +27,7 @@ public PageInfoContainer(ConnectionContainer connectionContainer) { } @Override - public Object processFetch(Environment context, PersistentResourceFetcher fetcher) { + public Object processFetch(Environment context) { String fieldName = context.field.getName(); ConnectionContainer connectionContainer = getConnectionContainer(); Optional pagination = connectionContainer.getPagination(); diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PersistentResourceContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PersistentResourceContainer.java index 3060a50a0b..57a44818c1 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PersistentResourceContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/PersistentResourceContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/RootContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/RootContainer.java index 6449e334e7..b0e9ee52c3 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/RootContainer.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/containers/RootContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -12,12 +12,13 @@ * Root container for GraphQL requests. */ public class RootContainer implements GraphQLContainer { + @Override - public Object processFetch(Environment context, PersistentResourceFetcher fetcher) { + public GraphQLContainer processFetch(Environment context) { String entityName = context.field.getName(); String aliasName = context.field.getAlias(); - return fetcher.fetchObject( + return PersistentResourceFetcher.fetchObject( context.requestScope, context.requestScope .getProjectionInfo() diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java index 70bd6f9bd5..a3ba7c0178 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLEntityProjectionMaker.java @@ -63,19 +63,19 @@ */ @Slf4j public class GraphQLEntityProjectionMaker { - private final ElideSettings elideSettings; - private final EntityDictionary entityDictionary; - private final FilterDialect filterDialect; + protected final ElideSettings elideSettings; + protected final EntityDictionary entityDictionary; + protected final FilterDialect filterDialect; - private final VariableResolver variableResolver; - private final FragmentResolver fragmentResolver; + protected final VariableResolver variableResolver; + protected final FragmentResolver fragmentResolver; - private final Map relationshipMap = new HashMap<>(); - private final Map rootProjections = new HashMap<>(); - private final Map attributeMap = new HashMap<>(); + protected final Map relationshipMap = new HashMap<>(); + protected final Map rootProjections = new HashMap<>(); + protected final Map attributeMap = new HashMap<>(); - private final GraphQLNameUtils nameUtils; - private final String apiVersion; + protected final GraphQLNameUtils nameUtils; + protected final String apiVersion; /** * Constructor. @@ -127,8 +127,9 @@ public GraphQLProjectionInfo make(String query) { if (definition instanceof OperationDefinition) { // Operations would be converted into EntityProjection tree OperationDefinition operationDefinition = (OperationDefinition) definition; - if (operationDefinition.getOperation() == OperationDefinition.Operation.SUBSCRIPTION) { - // TODO: support SUBSCRIPTION + + //Only allow supported operations. + if (! supportsOperationType(operationDefinition.getOperation())) { return; } @@ -161,17 +162,19 @@ private void addRootProjection(SelectionSet selectionSet) { Field rootSelectionField = (Field) rootSelection; String entityName = rootSelectionField.getName(); String aliasName = rootSelectionField.getAlias(); - if (SCHEMA.hasName(entityName) || TYPE.hasName(entityName)) { - // '__schema' and '__type' would not be handled by entity projection + + //_service comes from Apollo federation spec + if ("_service".equals(entityName) || SCHEMA.hasName(entityName) || TYPE.hasName(entityName)) { + // '_service' and '__schema' and '__type' would not be handled by entity projection return; } - Type entityType = entityDictionary.getEntityClass(rootSelectionField.getName(), apiVersion); + + Type entityType = getRootEntity(rootSelectionField.getName(), apiVersion); if (entityType == null) { throw new InvalidEntityBodyException(String.format("Unknown entity {%s}.", rootSelectionField.getName())); } - String keyName = GraphQLProjectionInfo.computeProjectionKey(aliasName, entityName); if (rootProjections.containsKey(keyName)) { throw new InvalidEntityBodyException( @@ -194,7 +197,7 @@ private void addRootProjection(SelectionSet selectionSet) { private EntityProjection createProjection(Type entityType, Field entityField) { final EntityProjectionBuilder projectionBuilder = EntityProjection.builder() .type(entityType) - .pagination(PaginationImpl.getDefaultPagination(entityType, elideSettings)); + .pagination(getDefaultPagination(entityType)); // Add the Entity Arguments to the Projection projectionBuilder.arguments(new HashSet<>( @@ -565,7 +568,9 @@ private List getArguments(Field attribute .name(clientArgument.get().getName()) .value( variableResolver.resolveValue( - clientArgument.get().getValue())) + clientArgument.get().getValue(), + Optional.of(argumentType.getType()) + )) .build()); //If not, check if there is a default value for this argument. @@ -578,4 +583,19 @@ private List getArguments(Field attribute })); return arguments; } + + protected Type getRootEntity(String entityName, String apiVersion) { + return entityDictionary.getEntityClass(entityName, apiVersion); + } + + protected boolean supportsOperationType(OperationDefinition.Operation operation) { + if (operation != OperationDefinition.Operation.SUBSCRIPTION) { + return true; + } + return false; + } + + protected Pagination getDefaultPagination(Type entityType) { + return PaginationImpl.getDefaultPagination(entityType, elideSettings); + } } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLQuery.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLQuery.java new file mode 100644 index 0000000000..634fda8ca7 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/GraphQLQuery.java @@ -0,0 +1,23 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.parser; + +import lombok.Builder; +import lombok.Value; + +import java.util.Map; + +/** + * Represents a complete GraphQL query including its envelope. + */ +@Value +@Builder +public class GraphQLQuery { + private String query; + private String operationName; + private Map variables; +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/QueryParser.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/QueryParser.java new file mode 100644 index 0000000000..5691ffb917 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/QueryParser.java @@ -0,0 +1,76 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.parser; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/** + * Utility functions to parse the envelope that wraps every GraphQL query. + */ +public interface QueryParser { + + static final String QUERY = "query"; + static final String OPERATION_NAME = "operationName"; + static final String VARIABLES = "variables"; + + /** + * Parse a document which could consist of 1 or more GraphQL queries. + * @param message The document to parse. + * @param mapper An object mapper to do JSON parsing. + * @return A list of 1 or more parsed GraphQL queries. + * @throws IOException If there is a JSON processing error. + */ + default List parseDocument(String message, ObjectMapper mapper) throws IOException { + List results = new ArrayList<>(); + JsonNode topLevel = mapper.readTree(message); + + if (topLevel.isArray()) { + Iterator nodeIterator = topLevel.iterator(); + + while (nodeIterator.hasNext()) { + JsonNode document = nodeIterator.next(); + + results.add(parseQuery(document, mapper)); + } + } else { + results.add(parseQuery(topLevel, mapper)); + } + + return results; + } + + /** + * Parse a GraphQL query. + * @param topLevel The query JsonNode to further parse. + * @param mapper An object mapper to do JSON parsing. + * @return A parsed GraphQL queries. + * @throws IOException If there is a JSON processing error. + */ + default GraphQLQuery parseQuery(JsonNode topLevel, ObjectMapper mapper) throws IOException { + String query = topLevel.has(QUERY) ? topLevel.get(QUERY).asText() : null; + String operationName = ""; + + Map variables = new HashMap<>(); + if (topLevel.has(VARIABLES) && !topLevel.get(VARIABLES).isNull()) { + variables = mapper.convertValue(topLevel.get(VARIABLES), Map.class); + } + + if (topLevel.has(OPERATION_NAME) && !topLevel.get(OPERATION_NAME).isNull()) { + operationName = topLevel.get(OPERATION_NAME).asText(); + } + + return new GraphQLQuery(query, operationName, variables); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/SubscriptionEntityProjectionMaker.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/SubscriptionEntityProjectionMaker.java new file mode 100644 index 0000000000..e4457bf55b --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/SubscriptionEntityProjectionMaker.java @@ -0,0 +1,36 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.parser; + +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.core.request.Pagination; +import com.yahoo.elide.core.type.Type; +import graphql.language.OperationDefinition; + +import java.util.Map; + +public class SubscriptionEntityProjectionMaker extends GraphQLEntityProjectionMaker { + public SubscriptionEntityProjectionMaker( + ElideSettings elideSettings, + Map variables, + String apiVersion) { + super(elideSettings, variables, apiVersion); + } + + @Override + protected boolean supportsOperationType(OperationDefinition.Operation operation) { + if (operation == OperationDefinition.Operation.SUBSCRIPTION) { + return true; + } + return false; + } + + @Override + protected Pagination getDefaultPagination(Type entityType) { + return null; + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/VariableResolver.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/VariableResolver.java index d8d58a5e46..436e46ecc5 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/VariableResolver.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/parser/VariableResolver.java @@ -7,6 +7,7 @@ package com.yahoo.elide.graphql.parser; import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; import graphql.language.ArrayValue; import graphql.language.BooleanValue; import graphql.language.EnumValue; @@ -24,7 +25,9 @@ import graphql.language.VariableReference; import java.util.HashMap; +import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; /** @@ -87,11 +90,24 @@ private void addVariable(VariableDefinition definition) { * @return resolved value of given variable */ public Object resolveValue(Value value) { + return resolveValue(value, Optional.empty()); + } + + /** + * Resolve the real value of a GraphQL {@link Value} object. Use variables in request if necessary. + * + * @param value requested variable value + * @param resolveTo the Elide type to resolve to + * @return resolved value of given variable + */ + public Object resolveValue(Value value, Optional> resolveTo) { if (value instanceof BooleanValue) { return ((BooleanValue) value).isValue(); } if (value instanceof EnumValue) { - // TODO + if (resolveTo.isPresent()) { + return CoerceUtil.coerce(((EnumValue) value).getName().toUpperCase(Locale.ROOT), resolveTo.get()); + } throw new BadRequestException("Enum value is not supported."); } if (value instanceof FloatValue) { diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionDataFetcher.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionDataFetcher.java new file mode 100644 index 0000000000..5c84d04219 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionDataFetcher.java @@ -0,0 +1,88 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.exceptions.InvalidEntityBodyException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.graphql.Environment; +import com.yahoo.elide.graphql.NonEntityDictionary; +import com.yahoo.elide.graphql.QueryLogger; +import com.yahoo.elide.graphql.RelationshipOp; +import com.yahoo.elide.graphql.subscriptions.containers.SubscriptionNodeContainer; +import graphql.language.OperationDefinition; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import io.reactivex.BackpressureStrategy; +import io.reactivex.Flowable; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; + +/** + * Data Fetcher which fetches Elide subscription models. + */ +@Slf4j +public class SubscriptionDataFetcher implements DataFetcher, QueryLogger { + + private final NonEntityDictionary nonEntityDictionary; + private final Integer bufferSize; + + /** + * Constructor. + * @param nonEntityDictionary Entity dictionary for types that are not Elide models. + */ + public SubscriptionDataFetcher(NonEntityDictionary nonEntityDictionary) { + this(nonEntityDictionary, 100); + } + + /** + * Constructor. + * @param nonEntityDictionary Entity dictionary for types that are not Elide models. + * @param bufferSize Internal buffer for reactive streams. + */ + public SubscriptionDataFetcher(NonEntityDictionary nonEntityDictionary, int bufferSize) { + this.nonEntityDictionary = nonEntityDictionary; + this.bufferSize = bufferSize; + } + + @Override + public Object get(DataFetchingEnvironment environment) throws Exception { + OperationDefinition.Operation op = environment.getOperationDefinition().getOperation(); + if (op != OperationDefinition.Operation.SUBSCRIPTION) { + throw new InvalidEntityBodyException(String.format("%s not supported for subscription models.", op)); + } + + /* build environment object, extracts required fields */ + Environment context = new Environment(environment, nonEntityDictionary); + + + /* safe enable debugging */ + if (log.isDebugEnabled()) { + logContext(log, RelationshipOp.FETCH, context); + } + + if (context.isRoot()) { + String entityName = context.field.getName(); + String aliasName = context.field.getAlias(); + EntityProjection projection = context.requestScope + .getProjectionInfo() + .getProjection(aliasName, entityName); + + Flowable recordPublisher = + PersistentResource.loadRecords(projection, new ArrayList<>(), context.requestScope) + .toFlowable(BackpressureStrategy.BUFFER) + .onBackpressureBuffer(bufferSize, true, false); + + return recordPublisher.map(SubscriptionNodeContainer::new); + } + + //If this is not the root, instead of returning a reactive publisher, we process same + //as the PersistentResourceFetcher. + return context.container.processFetch(context); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionModelBuilder.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionModelBuilder.java index 41bd905bac..a001a7b546 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionModelBuilder.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionModelBuilder.java @@ -6,6 +6,7 @@ package com.yahoo.elide.graphql.subscriptions; import static graphql.schema.GraphQLArgument.newArgument; +import static graphql.schema.GraphQLEnumType.newEnum; import static graphql.schema.GraphQLFieldDefinition.newFieldDefinition; import static graphql.schema.GraphQLObjectType.newObject; import com.yahoo.elide.core.dictionary.EntityDictionary; @@ -13,13 +14,19 @@ import com.yahoo.elide.core.type.Type; import com.yahoo.elide.graphql.GraphQLConversionUtils; import com.yahoo.elide.graphql.GraphQLNameUtils; +import com.yahoo.elide.graphql.GraphQLScalars; import com.yahoo.elide.graphql.NonEntityDictionary; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; +import com.yahoo.elide.graphql.subscriptions.hooks.TopicType; import com.google.common.collect.Sets; import graphql.Scalars; +import graphql.language.EnumTypeDefinition; import graphql.schema.DataFetcher; import graphql.schema.FieldCoordinates; import graphql.schema.GraphQLArgument; import graphql.schema.GraphQLCodeRegistry; +import graphql.schema.GraphQLEnumType; import graphql.schema.GraphQLFieldDefinition; import graphql.schema.GraphQLList; import graphql.schema.GraphQLObjectType; @@ -50,6 +57,8 @@ public class SubscriptionModelBuilder { private Set> excludedEntities; //Client controlled models to skip. private Set> relationshipTypes; //Keeps track of which relationship models need to be built. + public static final String TOPIC_ARGUMENT = "topic"; + /** * Class constructor, constructs the custom arguments to handle mutations. * @param entityDictionary elide entity dictionary @@ -97,19 +106,33 @@ public GraphQLSchema build() { GraphQLObjectType.Builder root = newObject().name("Subscription"); for (Type clazz : subscriptionClasses) { - Subscription include = entityDictionary.getAnnotation(clazz, Subscription.class); - if (include == null) { + Subscription subscription = entityDictionary.getAnnotation(clazz, Subscription.class); + if (subscription == null) { continue; } + GraphQLObjectType subscriptionType = buildQueryObject(clazz); - for (Subscription.Operation op : include.operations()) { - String subscriptionName = nameUtils.toSubscriptionName(clazz, op); - root.field(newFieldDefinition() - .name(subscriptionName) - .description(EntityDictionary.getEntityDescription(clazz)) - .argument(filterArgument) - .type(subscriptionType)); + String entityName = entityDictionary.getJsonAliasFor(clazz); + + GraphQLFieldDefinition.Builder rootFieldDefinitionBuilder = newFieldDefinition() + .name(entityName) + .description(EntityDictionary.getEntityDescription(clazz)) + .argument(filterArgument) + .type(subscriptionType); + + if (subscription.operations() != null && subscription.operations().length > 0) { + GraphQLEnumType.Builder topicTypeBuilder = newEnum().name(nameUtils.toTopicName(clazz)); + + for (Subscription.Operation operation : subscription.operations()) { + TopicType topicType = TopicType.fromOperation(operation); + topicTypeBuilder.value(topicType.name(), topicType); } + topicTypeBuilder.definition(EnumTypeDefinition.newEnumTypeDefinition().build()); + rootFieldDefinitionBuilder.argument( + GraphQLArgument.newArgument().name(TOPIC_ARGUMENT).type(topicTypeBuilder.build()).build()); + } + + root.field(rootFieldDefinitionBuilder.build()); } GraphQLObjectType queryRoot = root.build(); @@ -165,7 +188,7 @@ private GraphQLObjectType buildQueryObject(Type entityClass) { builder.field(newFieldDefinition() .name(id) - .type(Scalars.GraphQLID)); + .type(GraphQLScalars.GRAPHQL_DEFERRED_ID)); for (String attribute : entityDictionary.getAttributes(entityClass)) { Type attributeClass = entityDictionary.getType(entityClass, attribute); diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/Subscription.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/annotations/Subscription.java similarity index 64% rename from elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/Subscription.java rename to elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/annotations/Subscription.java index 8aa8bef21e..3dca62618e 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/Subscription.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/annotations/Subscription.java @@ -3,11 +3,11 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.graphql.subscriptions; +package com.yahoo.elide.graphql.subscriptions.annotations; -import static com.yahoo.elide.graphql.subscriptions.Subscription.Operation.CREATE; -import static com.yahoo.elide.graphql.subscriptions.Subscription.Operation.DELETE; -import static com.yahoo.elide.graphql.subscriptions.Subscription.Operation.UPDATE; +import static com.yahoo.elide.graphql.subscriptions.annotations.Subscription.Operation.CREATE; +import static com.yahoo.elide.graphql.subscriptions.annotations.Subscription.Operation.DELETE; +import static com.yahoo.elide.graphql.subscriptions.annotations.Subscription.Operation.UPDATE; import static java.lang.annotation.ElementType.TYPE; import static java.lang.annotation.RetentionPolicy.RUNTIME; @@ -28,7 +28,7 @@ enum Operation { /** * Notify subscribers whenever a model is manipulated by the given operations. - * @return + * @return operation topics to post on. */ Operation[] operations() default { CREATE, UPDATE, DELETE }; } diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionField.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/annotations/SubscriptionField.java similarity index 89% rename from elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionField.java rename to elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/annotations/SubscriptionField.java index b9d4a836a3..1bc09fa8bd 100644 --- a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/SubscriptionField.java +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/annotations/SubscriptionField.java @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ -package com.yahoo.elide.graphql.subscriptions; +package com.yahoo.elide.graphql.subscriptions.annotations; import static java.lang.annotation.ElementType.FIELD; import static java.lang.annotation.ElementType.METHOD; diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/containers/SubscriptionNodeContainer.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/containers/SubscriptionNodeContainer.java new file mode 100644 index 0000000000..53ae8bf05d --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/containers/SubscriptionNodeContainer.java @@ -0,0 +1,50 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.graphql.subscriptions.containers; + +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.dictionary.RelationshipType; +import com.yahoo.elide.core.request.Relationship; +import com.yahoo.elide.graphql.Environment; +import com.yahoo.elide.graphql.containers.NodeContainer; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Container for subscription nodes. + */ +public class SubscriptionNodeContainer extends NodeContainer { + + public SubscriptionNodeContainer(PersistentResource persistentResource) { + super(persistentResource); + } + + @Override + protected Object fetchRelationship(Environment context, Relationship relationship) { + RelationshipType type = context.parentResource.getRelationshipType(relationship.getName()); + + if (type.isToOne()) { + Set resources = (Set) context.parentResource + .getRelationCheckedFiltered(relationship) + .toList(LinkedHashSet::new).blockingGet(); + if (resources.size() > 0) { + return new SubscriptionNodeContainer(resources.iterator().next()); + } else { + return null; + } + } else { + Set resources = (Set) context.parentResource + .getRelationCheckedFiltered(relationship) + .toList(LinkedHashSet::new).blockingGet(); + + return resources.stream() + .map(SubscriptionNodeContainer::new) + .collect(Collectors.toList()); + } + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/NotifyTopicLifeCycleHook.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/NotifyTopicLifeCycleHook.java new file mode 100644 index 0000000000..6c7fa08ea6 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/NotifyTopicLifeCycleHook.java @@ -0,0 +1,97 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.hooks; + +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.lifecycle.CRUDEvent; +import com.yahoo.elide.core.lifecycle.LifeCycleHook; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; +import com.yahoo.elide.core.type.Type; +import com.google.gson.Gson; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Optional; +import java.util.function.Function; +import javax.inject.Inject; +import javax.jms.ConnectionFactory; +import javax.jms.Destination; +import javax.jms.JMSContext; +import javax.jms.JMSProducer; + +/** + * Life cycle hook that sends serialized model events to a JMS topic. + * This will be registered automatically by Elide for all models that have the Subscription annotation. + * + * @param The model type. + */ +@Slf4j +@NoArgsConstructor //For injection +public class NotifyTopicLifeCycleHook implements LifeCycleHook { + + @Inject + private ConnectionFactory connectionFactory; + + @Inject + private Function createProducer; + + private Gson gson; + + public NotifyTopicLifeCycleHook( + ConnectionFactory connectionFactory, + Function createProducer, + Gson gson + ) { + this.connectionFactory = connectionFactory; + this.createProducer = createProducer; + this.gson = gson; + } + + @Override + public void execute( + LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + CRUDEvent event) { + + PersistentResource resource = (PersistentResource) event.getResource(); + + Type modelType = resource.getResourceType(); + TopicType topicType = TopicType.fromOperation(operation); + String topicName = topicType.toTopicName(modelType, resource.getDictionary()); + + publish(resource.getObject(), topicName); + } + + @Override + public void execute( + LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + T elideEntity, + RequestScope requestScope, + Optional changes) { + //NOOP + } + + /** + * Publishes an object to a JMS topic. + * @param object The object to publish. + * @param topicName The topic name to publish to. + */ + public void publish(T object, String topicName) { + try (JMSContext context = connectionFactory.createContext()) { + + JMSProducer producer = createProducer.apply(context); + Destination destination = context.createTopic(topicName); + + String message = gson.toJson(object); + log.debug("Serializing {}", message); + producer.send(destination, message); + } + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionExclusionStrategy.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionExclusionStrategy.java new file mode 100644 index 0000000000..8a6ccbe7a8 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionExclusionStrategy.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.hooks; + +import com.yahoo.elide.core.dictionary.EntityBinding; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; +import com.google.gson.ExclusionStrategy; +import com.google.gson.FieldAttributes; + +import java.lang.annotation.Annotation; + +/** + * Gson exclusion strategy that only serializes ID fields and fields annotated + * with SubscriptionField. + */ +public class SubscriptionExclusionStrategy implements ExclusionStrategy { + + @Override + public boolean shouldSkipField(FieldAttributes fieldAttributes) { + + if (fieldAttributes.getAnnotation(SubscriptionField.class) != null) { + return false; + } + + for (Class idAnnotation : EntityBinding.ID_ANNOTATIONS) { + if (fieldAttributes.getAnnotation(idAnnotation) != null) { + return false; + } + } + return true; + } + + @Override + public boolean shouldSkipClass(Class aClass) { + return false; + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionFieldSerde.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionFieldSerde.java new file mode 100644 index 0000000000..6e28ca4339 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionFieldSerde.java @@ -0,0 +1,57 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.graphql.subscriptions.hooks; + +import com.yahoo.elide.core.utils.coerce.converters.Serde; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import java.lang.reflect.Type; + +/** + * Custom GSON serializer and deserializer that uses an Elide serde to convert a subscription field + * to and from a primitive type. The Serde must support deserialization from a String. + * @param The object type being serialized or deserialized. + */ +public class SubscriptionFieldSerde implements JsonSerializer, JsonDeserializer { + Serde elideSerde; + + public SubscriptionFieldSerde(Serde elideSerde) { + this.elideSerde = elideSerde; + } + + @Override + public JsonElement serialize(T t, Type type, JsonSerializationContext jsonSerializationContext) { + if (t == null) { + return JsonNull.INSTANCE; + } + return new JsonPrimitive(String.valueOf(elideSerde.serialize(t))); + } + + @Override + public T deserialize(JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext) + throws JsonParseException { + if (jsonElement.isJsonNull()) { + return null; + } + + if (jsonElement.isJsonPrimitive()) { + JsonPrimitive primitive = jsonElement.getAsJsonPrimitive(); + if (primitive.isString()) { + return elideSerde.deserialize(primitive.getAsString()); + } + } + + throw new UnsupportedOperationException("Cannot deserialization subscription field: " + + jsonElement.getAsString()); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionScanner.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionScanner.java new file mode 100644 index 0000000000..804ce439a9 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionScanner.java @@ -0,0 +1,123 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.hooks; + +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; +import com.google.common.base.Preconditions; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import lombok.Builder; + +import java.util.function.Function; +import javax.jms.ConnectionFactory; +import javax.jms.JMSContext; +import javax.jms.JMSProducer; +import javax.jms.Message; + +/** + * Scans for subscription annotations and registers lifecycle hooks to update JMS topics. + */ +@Builder +public class SubscriptionScanner { + private ConnectionFactory connectionFactory; + private EntityDictionary dictionary; + private ClassScanner scanner; + + @Builder.Default + private long timeToLive = Message.DEFAULT_TIME_TO_LIVE; + @Builder.Default + private int deliveryMode = Message.DEFAULT_DELIVERY_MODE; + @Builder.Default + private long deliveryDelay = Message.DEFAULT_DELIVERY_DELAY; + @Builder.Default + private int messagePriority = Message.DEFAULT_PRIORITY; + + public void bindLifecycleHooks() { + + GsonBuilder gsonBuilder = new GsonBuilder(); + CoerceUtil.getSerdes().forEach((cls, serde) -> { + gsonBuilder.registerTypeAdapter(cls, new SubscriptionFieldSerde(serde)); + }); + gsonBuilder.addSerializationExclusionStrategy(new SubscriptionExclusionStrategy()).serializeNulls(); + + Gson gson = gsonBuilder.create(); + + Function producerFactory = (context) -> { + JMSProducer producer = context.createProducer(); + producer.setTimeToLive(timeToLive); + producer.setDeliveryMode(deliveryMode); + producer.setDeliveryDelay(deliveryDelay); + producer.setPriority(messagePriority); + return producer; + }; + + scanner.getAnnotatedClasses(Subscription.class).forEach(modelType -> { + Subscription subscription = modelType.getAnnotation(Subscription.class); + Preconditions.checkNotNull(subscription); + + Subscription.Operation[] operations = subscription.operations(); + + for (Subscription.Operation operation : operations) { + switch (operation) { + case UPDATE: { + addUpdateHooks(ClassType.of(modelType), dictionary, producerFactory, gson); + break; + } + case DELETE: { + dictionary.bindTrigger( + modelType, + LifeCycleHookBinding.Operation.DELETE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + new NotifyTopicLifeCycleHook(connectionFactory, producerFactory, gson), + false + ); + break; + } + case CREATE: { + dictionary.bindTrigger( + modelType, + LifeCycleHookBinding.Operation.CREATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + new NotifyTopicLifeCycleHook(connectionFactory, producerFactory, gson), + false + ); + break; + } + } + } + }); + } + + protected void addUpdateHooks( + Type model, + EntityDictionary dictionary, + Function producerFactory, + Gson gson + ) { + dictionary.getAllExposedFields(model).stream().forEach(fieldName -> { + SubscriptionField subscriptionField = + dictionary.getAttributeOrRelationAnnotation(model, SubscriptionField.class, fieldName); + + if (subscriptionField != null) { + dictionary.bindTrigger( + model, + fieldName, + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + new NotifyTopicLifeCycleHook(connectionFactory, producerFactory, gson) + ); + } + }); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/TopicType.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/TopicType.java new file mode 100644 index 0000000000..c9a9c4d996 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/hooks/TopicType.java @@ -0,0 +1,87 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.hooks; + +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.DELETE; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import lombok.Getter; + +/** + * JMS Topic Names. + */ +@Getter +public enum TopicType { + ADDED("Added", CREATE), + DELETED("Deleted", DELETE), + UPDATED("Updated", UPDATE), + CUSTOM("", null); + + private final String topicSuffix; + private final LifeCycleHookBinding.Operation operation; + + /** + * Constructor. + * @param topicSuffix The suffix of the topic name. + */ + TopicType(String topicSuffix, LifeCycleHookBinding.Operation operation) { + this.topicSuffix = topicSuffix; + this.operation = operation; + } + + /** + * Converts a TopicType to a JMS topic name. + * @param type Elide model type. + * @param dictionary Elide entity dictionary. + * @return a JMS topic name. + */ + public String toTopicName(Type type, EntityDictionary dictionary) { + return dictionary.getJsonAliasFor(type) + topicSuffix; + } + + /** + * Converts a LifeCycleHookBinding to a topic type. + * @param op The lifecyle operation + * @return The corresponding topic type. + */ + public static TopicType fromOperation(LifeCycleHookBinding.Operation op) { + switch (op) { + case CREATE: { + return ADDED; + } + case DELETE: { + return DELETED; + } + default : { + return UPDATED; + } + } + } + + /** + * Converts a LifeCycleHookBinding to a topic type. + * @param op The lifecyle operation + * @return The corresponding topic type. + */ + public static TopicType fromOperation(Subscription.Operation op) { + switch (op) { + case CREATE: { + return ADDED; + } + case DELETE: { + return DELETED; + } + default : { + return UPDATED; + } + } + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/ConnectionInfo.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/ConnectionInfo.java new file mode 100644 index 0000000000..70cf1bf71d --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/ConnectionInfo.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket; + +import com.yahoo.elide.core.security.User; +import lombok.Builder; +import lombok.Value; + +import java.util.List; +import java.util.Map; + +/** + * Information about the sessions/connection passed to the request handler. + */ +@Value +@Builder +public class ConnectionInfo { + /** + * Return a Elide user object for the session. + * @return Elide user. + */ + private User user; + + + /** + * Return the URL path for this request. + * @return URL path. + */ + private String baseUrl; + + /** + * Get a map of parameters for the session. + * @return map of parameters. + */ + private Map> parameters; + + /** + * Gets the API version associated with this websocket. + */ + private String getApiVersion; +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/RequestHandler.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/RequestHandler.java new file mode 100644 index 0000000000..689e864ea9 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/RequestHandler.java @@ -0,0 +1,317 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.graphql.GraphQLRequestScope; +import com.yahoo.elide.graphql.QueryRunner; +import com.yahoo.elide.graphql.parser.GraphQLProjectionInfo; +import com.yahoo.elide.graphql.parser.SubscriptionEntityProjectionMaker; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Complete; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Error; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Next; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Ping; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Subscribe; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import graphql.ErrorClassification; +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.GraphQLError; +import graphql.language.SourceLocation; +import lombok.extern.slf4j.Slf4j; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Handles either a single GraphQL schema request or a subscription request. + */ +@Slf4j +public class RequestHandler implements Closeable { + protected DataStore topicStore; + protected DataStoreTransaction transaction; + protected Elide elide; + protected GraphQL api; + protected UUID requestID; + protected String protocolID; + protected SessionHandler sessionHandler; + protected ConnectionInfo connectionInfo; + protected boolean sendPingOnSubscribe; + protected AtomicBoolean isOpen = new AtomicBoolean(true); + protected boolean verboseErrors = false; + + /** + * Constructor. + * @param sessionHandler Handles all requests for the given session. + * @param topicStore The JMS data store. + * @param elide Elide instance. + * @param api GraphQL api. + * @param protocolID The graphql-ws protocol message ID this request. + * @param requestID The Elide request UUID for this request. + * @param connectionInfo Meta info about the session. + */ + public RequestHandler( + SessionHandler sessionHandler, + DataStore topicStore, + Elide elide, + GraphQL api, + String protocolID, + UUID requestID, + ConnectionInfo connectionInfo, + boolean sendPingOnSubscribe, + boolean verboseErrors) { + this.sessionHandler = sessionHandler; + this.topicStore = topicStore; + this.elide = elide; + this.api = api; + this.requestID = requestID; + this.protocolID = protocolID; + this.connectionInfo = connectionInfo; + this.transaction = null; + this.sendPingOnSubscribe = sendPingOnSubscribe; + this.verboseErrors = verboseErrors; + } + + /** + * Close this session. Synchronized to protect transaction. + * @throws IOException If there is a problem closing the underlying transaction. + */ + public synchronized void close() throws IOException { + if (! isOpen.compareAndExchange(true, false)) { + return; + } + if (transaction != null) { + transaction.close(); + elide.getTransactionRegistry().removeRunningTransaction(requestID); + } + + sessionHandler.close(protocolID); + log.debug("Closed Request Handler"); + } + + /** + * Handles an incoming GraphQL query. + * @param subscribeRequest The GraphQL query. + */ + public void handleRequest(Subscribe subscribeRequest) { + ExecutionResult executionResult = null; + try { + executionResult = executeRequest(subscribeRequest); + //This would be a subscription creation error. + } catch (RuntimeException e) { + log.error("UNEXPECTED RuntimeException: {}", e.getMessage()); + ElideResponse response = QueryRunner.handleRuntimeException(elide, e, verboseErrors); + safeSendError(response.getBody()); + safeClose(); + } + + //GraphQL schema requests or other queries will take this route. + if (!(executionResult.getData() instanceof Publisher)) { + safeSendNext(executionResult); + safeSendComplete(); + safeClose(); + return; + } + + Publisher resultPublisher = executionResult.getData(); + + //This would be a subscription creation error. + if (resultPublisher == null) { + safeSendError(executionResult.getErrors().toArray(GraphQLError[]::new)); + safeClose(); + return; + } + + //This would be a subscription creation success. + resultPublisher.subscribe(new ExecutionResultSubscriber()); + } + + public synchronized ExecutionResult executeRequest(Subscribe subscribeRequest) { + if (transaction != null) { + throw new IllegalStateException("Already handling an active request."); + } + + transaction = topicStore.beginReadTransaction(); + elide.getTransactionRegistry().addRunningTransaction(requestID, transaction); + + ElideSettings settings = elide.getElideSettings(); + + GraphQLProjectionInfo projectionInfo = + new SubscriptionEntityProjectionMaker(settings, + subscribeRequest.getPayload().getVariables(), + connectionInfo.getGetApiVersion()).make(subscribeRequest.getPayload().getQuery()); + + GraphQLRequestScope requestScope = new GraphQLRequestScope( + connectionInfo.getBaseUrl(), + transaction, + connectionInfo.getUser(), + connectionInfo.getGetApiVersion(), + settings, + projectionInfo, + requestID, + connectionInfo.getParameters()); + + ExecutionInput executionInput = ExecutionInput.newExecutionInput() + .query(subscribeRequest.getPayload().getQuery()) + .operationName(subscribeRequest.getPayload().getOperationName()) + .variables(subscribeRequest.getPayload().getVariables()) + .localContext(requestScope) + .build(); + + log.info("Processing GraphQL query:\n{}", subscribeRequest.getPayload().getQuery()); + + return api.execute(executionInput); + } + + protected void sendMessage(String message) { + if (isOpen.get()) { + sessionHandler.sendMessage(message); + return; + } + log.debug("UNEXPECTED Sending message on closed handler: {}", message); + } + + protected void safeSendPing() { + ObjectMapper mapper = elide.getElideSettings().getMapper().getObjectMapper(); + Ping ping = new Ping(); + + try { + sendMessage(mapper.writeValueAsString(ping)); + } catch (JsonProcessingException e) { + log.error("UNEXPECTED Json Serialization Error {}", e.getMessage()); + safeClose(); + } + } + + protected void safeSendNext(ExecutionResult result) { + log.debug("Sending Next {}", result); + ObjectMapper mapper = elide.getElideSettings().getMapper().getObjectMapper(); + Next next = Next.builder() + .result(result) + .id(protocolID) + .build(); + try { + sendMessage(mapper.writeValueAsString(next)); + } catch (JsonProcessingException e) { + log.error("UNEXPECTED Json Serialization Error {}", e.getMessage()); + safeClose(); + } + } + + protected void safeSendComplete() { + log.debug("Sending Complete"); + ObjectMapper mapper = elide.getElideSettings().getMapper().getObjectMapper(); + Complete complete = Complete.builder() + .id(protocolID) + .build(); + try { + sendMessage(mapper.writeValueAsString(complete)); + } catch (JsonProcessingException e) { + log.error("UNEXPECTED Json Serialization Error {}", e.getMessage()); + safeClose(); + } + } + + protected void safeSendError(GraphQLError[] errors) { + log.debug("Sending Error {}", errors); + ObjectMapper mapper = elide.getElideSettings().getMapper().getObjectMapper(); + Error error = Error.builder() + .id(protocolID) + .payload(errors) + .build(); + try { + sendMessage(mapper.writeValueAsString(error)); + } catch (JsonProcessingException e) { + log.error("UNEXPECTED Json Serialization Error {}", e.getMessage()); + safeClose(); + } + } + + + protected void safeSendError(String message) { + GraphQLError error = new GraphQLError() { + @Override + public String getMessage() { + return message; + } + + @Override + public List getLocations() { + return null; + } + + @Override + public ErrorClassification getErrorType() { + return null; + } + }; + + GraphQLError[] errors = new GraphQLError[1]; + errors[0] = error; + safeSendError(errors); + } + + protected void safeClose() { + try { + close(); + } catch (Exception e) { + log.error("UNEXPECTED Exception during close {}", e.getMessage()); + } + } + + /** + * Reactive subscriber for GraphQL results. + */ + private class ExecutionResultSubscriber implements Subscriber { + + AtomicReference subscriptionRef = new AtomicReference<>(); + + @Override + public void onSubscribe(Subscription subscription) { + subscriptionRef.set(subscription); + + if (sendPingOnSubscribe) { + safeSendPing(); + } + subscription.request(1); + } + + @Override + public void onNext(ExecutionResult executionResult) { + log.debug("Next Result"); + safeSendNext(executionResult); + subscriptionRef.get().request(1); + } + + @Override + public void onError(Throwable t) { + log.error("UNEXPECTED Topic Error {}", t.getMessage()); + safeSendError(t.getMessage()); + safeClose(); + } + + @Override + public void onComplete() { + log.debug("Topic was terminated"); + safeSendComplete(); + safeClose(); + } + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/SessionHandler.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/SessionHandler.java new file mode 100644 index 0000000000..aa1b7e4c2d --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/SessionHandler.java @@ -0,0 +1,321 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket; + +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.CONNECTION_TIMEOUT; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.CloseCode.DUPLICATE_ID; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.INTERNAL_ERROR; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.INVALID_MESSAGE; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.MAX_SUBSCRIPTIONS; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.MULTIPLE_INIT; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.UNAUTHORIZED; +import com.yahoo.elide.Elide; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Complete; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.ConnectionAck; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.MessageType; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Pong; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Subscribe; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.base.Preconditions; +import graphql.GraphQL; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import javax.websocket.CloseReason; +import javax.websocket.Session; + +/** + * Implements the graphql-ws protocol (https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) + * over Elide subscriptions. + */ +@Slf4j +public class SessionHandler { + protected DataStore topicStore; + protected Elide elide; + protected GraphQL api; + protected Session wrappedSession; + protected Map activeRequests; + protected ConnectionInfo connectionInfo; + protected ObjectMapper mapper; + protected int connectionTimeoutMs; + protected int maxSubscriptions; + protected Thread timeoutThread; + protected boolean initialized = false; + protected boolean sendPingOnSubscribe = false; + protected boolean verboseErrors = false; + protected ExecutorService executorService; + protected boolean isOpen = true; + + /** + * Constructor. + * @param wrappedSession The underlying platform session object. + * @param topicStore The JMS data store. + * @param elide Elide instance. + * @param api GraphQL api. + * @param connectionTimeoutMs Connection timeout in milliseconds. + * @param maxSubscriptions Max number of outstanding subscriptions per web socket. + * @param connectionInfo Connection metadata. + * @param sendPingOnSubscribe Sends a ping on subscribe message (to aid with testing). + * @param verboseErrors Send verbose error messages. + * @param executorService Executor Service to launch threads. + */ + public SessionHandler( + Session wrappedSession, + DataStore topicStore, + Elide elide, + GraphQL api, + int connectionTimeoutMs, + int maxSubscriptions, + ConnectionInfo connectionInfo, + boolean sendPingOnSubscribe, + boolean verboseErrors, + ExecutorService executorService) { + Preconditions.checkState(maxSubscriptions > 0); + this.wrappedSession = wrappedSession; + this.topicStore = topicStore; + this.elide = elide; + this.api = api; + this.connectionInfo = connectionInfo; + this.mapper = elide.getMapper().getObjectMapper(); + this.activeRequests = new ConcurrentHashMap<>(); + this.connectionTimeoutMs = connectionTimeoutMs; + this.maxSubscriptions = maxSubscriptions; + this.sendPingOnSubscribe = sendPingOnSubscribe; + this.verboseErrors = verboseErrors; + if (executorService == null) { + this.executorService = Executors.newFixedThreadPool(maxSubscriptions); + } else { + this.executorService = executorService; + } + this.timeoutThread = new Thread(new ConnectionTimer()); + this.timeoutThread.start(); + } + + /** + * Close this session. + * @throws IOException If closing the session causes an error. + */ + public synchronized void close(CloseReason reason) throws IOException { + + log.debug("SessionHandler closing"); + + isOpen = false; + + //Iterator here to avoid concurrent modification exceptions. + Iterator> iterator = activeRequests.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry item = iterator.next(); + RequestHandler handler = item.getValue(); + handler.safeClose(); + + } + wrappedSession.close(reason); + + executorService.shutdownNow(); + log.debug("SessionHandler closed"); + } + + protected void close(String protocolID) { + activeRequests.remove(protocolID); + } + + /** + * Handles an incoming graphql-ws protocol message. + * @param message The protocol message. + */ + public void handleRequest(String message) { + log.debug("Received Message: {} {}", wrappedSession.getId(), message); + try { + JsonNode type = mapper.readTree(message).get("type"); + + if (type == null) { + safeClose(INVALID_MESSAGE); + return; + } + + MessageType messageType; + try { + messageType = MessageType.valueOf(type.textValue().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + safeClose(INVALID_MESSAGE); + return; + } + + switch (messageType) { + case PING: { + handlePing(); + return; + } + case PONG: { + //Ignore + return; + } + case CONNECTION_INIT: { + handleConnectionInit(); + return; + } + case COMPLETE: { + Complete complete = mapper.readValue(message, Complete.class); + handleComplete(complete); + return; + } + case SUBSCRIBE: { + Subscribe subscribe = mapper.readValue(message, Subscribe.class); + handleSubscribe(subscribe); + return; + } default: { + safeClose(INVALID_MESSAGE); + return; + } + } + } catch (JsonProcessingException e) { + safeClose(INVALID_MESSAGE); + } + } + + protected void handlePing() { + safeSendPong(); + } + + protected void handleConnectionInit() { + if (initialized) { + safeClose(MULTIPLE_INIT); + return; + } + + timeoutThread.interrupt(); + + safeSendConnectionAck(); + initialized = true; + } + + protected void handleSubscribe(Subscribe subscribe) { + if (!initialized) { + safeClose(UNAUTHORIZED); + return; + } + + String protocolID = subscribe.getId(); + + if (activeRequests.containsKey(protocolID)) { + safeClose(new CloseReason(WebSocketCloseReasons.createCloseCode(DUPLICATE_ID.getCode()), + "Subscriber for " + protocolID + " already exists")); + return; + } + + if (activeRequests.size() >= maxSubscriptions) { + safeClose(MAX_SUBSCRIPTIONS); + return; + } + + RequestHandler requestHandler = new RequestHandler(this, + topicStore, elide, api, protocolID, UUID.randomUUID(), + connectionInfo, sendPingOnSubscribe, verboseErrors); + + activeRequests.put(protocolID, requestHandler); + + executorService.submit(new Runnable() { + @Override + public void run() { + requestHandler.handleRequest(subscribe); + } + }); + } + + protected void handleComplete(Complete complete) { + String protocolID = complete.getId(); + RequestHandler handler = activeRequests.remove(protocolID); + + if (handler != null) { + handler.safeClose(); + } + + //Ignore otherwise + } + + protected void safeSendConnectionAck() { + ObjectMapper mapper = elide.getElideSettings().getMapper().getObjectMapper(); + ConnectionAck ack = new ConnectionAck(); + + try { + sendMessage(mapper.writeValueAsString(ack)); + } catch (JsonProcessingException e) { + log.error("UNEXPECTED Json Serialization Error {}", e.getMessage()); + safeClose(INTERNAL_ERROR); + } + } + + protected void safeSendPong() { + ObjectMapper mapper = elide.getElideSettings().getMapper().getObjectMapper(); + Pong pong = new Pong(); + + try { + sendMessage(mapper.writeValueAsString(pong)); + } catch (JsonProcessingException e) { + log.error("UNEXPECTED Json Serialization Error {}", e.getMessage()); + safeClose(INTERNAL_ERROR); + } + } + + protected void safeClose(CloseReason reason) { + log.debug("Closing session handler: {} {}", wrappedSession.getId(), reason); + try { + close(reason); + } catch (Exception e) { + log.error("UNEXPECTED: Closing {} failed for {}", wrappedSession.getId(), e.getMessage()); + } + } + + /** + * Send a text message on the native session. Synchronized to protect session and isOpen + * (which has dubious thread safety - even when async). + * @param message The message to send. + */ + public synchronized void sendMessage(String message) { + + //JSR 356 session is thread safe. + if (isOpen) { + try { + wrappedSession.getAsyncRemote().sendText(message); + return; + } catch (Exception e) { + log.debug("UNEXPECTED: Sending message {} failed for {}", message, e.getMessage()); + safeClose(INTERNAL_ERROR); + } + } + log.debug("UNEXPECTED: Sending message {} on closed session", message); + } + + /** + * Closes the socket if SUBSCRIBE has not been received in the allotted time. + */ + private class ConnectionTimer implements Runnable { + + @Override + public void run() { + try { + Thread.sleep(connectionTimeoutMs); + if (activeRequests.size() == 0) { + safeClose(CONNECTION_TIMEOUT); + } + } catch (InterruptedException e) { + log.debug("UNEXPECTED: Timeout thread interrupted: " + e.getMessage()); + } + } + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/SubscriptionWebSocket.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/SubscriptionWebSocket.java new file mode 100644 index 0000000000..c4430420d4 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/SubscriptionWebSocket.java @@ -0,0 +1,230 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import com.yahoo.elide.Elide; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.security.User; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.graphql.NonEntityDictionary; +import com.yahoo.elide.graphql.subscriptions.SubscriptionDataFetcher; +import com.yahoo.elide.graphql.subscriptions.SubscriptionModelBuilder; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons; +import graphql.GraphQL; +import graphql.execution.AsyncSerialExecutionStrategy; +import graphql.execution.SubscriptionExecutionStrategy; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import javax.websocket.OnClose; +import javax.websocket.OnError; +import javax.websocket.OnMessage; +import javax.websocket.OnOpen; +import javax.websocket.Session; +import javax.websocket.server.ServerEndpoint; + +/** + * JSR-356 Implementation of a web socket endpoint for GraphQL subscriptions. JSR-356 should allow + * cross-platform use for Spring, Quarkus, and other containers. + */ +@Slf4j +@ServerEndpoint(value = "/", subprotocols = { "graphql-transport-ws" }) +@Builder +public class SubscriptionWebSocket { + private Elide elide; + private ExecutorService executorService; + + @Builder.Default + private int connectTimeoutMs = 5000; + + @Builder.Default + private int maxSubscriptions = 30; + + @Builder.Default + private UserFactory userFactory = DEFAULT_USER_FACTORY; + + @Builder.Default + private long maxIdleTimeoutMs = 300000; + + @Builder.Default + private int maxMessageSize = 10000; + + @Builder.Default + private boolean sendPingOnSubscribe = false; + + @Builder.Default + private boolean verboseErrors = false; + + private final Map apis = new HashMap<>(); + private final ConcurrentMap openSessions = new ConcurrentHashMap<>(); + + public static final UserFactory DEFAULT_USER_FACTORY = session -> new User(session.getUserPrincipal()); + + /** + * There is no standard for authentication for web sockets. This interface delegates + * Elide user creation to the developer. This assumes authentication happens during the web socket handshake + * and not after during message exchange. + */ + @FunctionalInterface + public interface UserFactory { + User create(Session session); + } + + /** + * Constructor. + * @param elide Elide instance. + * @param executorService Thread pool for all websockets. If null each session will make its own. + * @param connectTimeoutMs Connection timeout. + * @param maxSubscriptions The maximum number of concurrent subscriptions per socket. + * @param userFactory A function which creates an Elide user given a session object. + * @param maxIdleTimeoutMs Max idle time on the websocket before disconnect. + * @param maxMessageSize Maximum message size allowed on this websocket. + * @param sendPingOnSubscribe testing option to ping the client when subscribe is ready. + * @param verboseErrors whether or not to send verbose errors. + */ + protected SubscriptionWebSocket( + Elide elide, + ExecutorService executorService, + int connectTimeoutMs, + int maxSubscriptions, + UserFactory userFactory, + long maxIdleTimeoutMs, + int maxMessageSize, + boolean sendPingOnSubscribe, + boolean verboseErrors + ) { + this.elide = elide; + this.executorService = executorService; + this.connectTimeoutMs = connectTimeoutMs; + this.maxSubscriptions = maxSubscriptions; + this.userFactory = userFactory; + this.sendPingOnSubscribe = sendPingOnSubscribe; + this.maxIdleTimeoutMs = maxIdleTimeoutMs; + this.maxMessageSize = maxMessageSize; + this.verboseErrors = verboseErrors; + + EntityDictionary dictionary = elide.getElideSettings().getDictionary(); + for (String apiVersion : dictionary.getApiVersions()) { + NonEntityDictionary nonEntityDictionary = + new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup); + + SubscriptionModelBuilder builder = new SubscriptionModelBuilder(dictionary, nonEntityDictionary, + new SubscriptionDataFetcher(nonEntityDictionary), NO_VERSION); + + GraphQL api = GraphQL.newGraphQL(builder.build()) + .queryExecutionStrategy(new AsyncSerialExecutionStrategy()) + .subscriptionExecutionStrategy(new SubscriptionExecutionStrategy()) + .build(); + + apis.put(apiVersion, api); + } + } + + /** + * Called on session open. + * @param session The platform specific session object. + * @throws IOException If there is an underlying error. + */ + @OnOpen + public void onOpen(Session session) throws IOException { + log.debug("Session Opening: {}", session.getId()); + SessionHandler subscriptionSession = createSessionHandler(session); + + session.setMaxIdleTimeout(maxIdleTimeoutMs); + session.setMaxTextMessageBufferSize(maxMessageSize); + session.setMaxBinaryMessageBufferSize(maxMessageSize); + + openSessions.put(session, subscriptionSession); + } + + /** + * Called on a new web socket message. + * @param session The platform specific session object. + * @param message The new message. + * @throws IOException If there is an underlying error. + */ + @OnMessage + public void onMessage(Session session, String message) throws IOException { + log.debug("Session Message: {} {}", session.getId(), message); + + SessionHandler handler = findSession(session); + if (handler != null) { + handler.handleRequest(message); + } else { + throw new IllegalStateException("Cannot locate session: " + session.getId()); + } + } + + /** + * Called on session close. + * @param session The platform specific session object. + * @throws IOException If there is an underlying error. + */ + @OnClose + public void onClose(Session session) throws IOException { + log.debug("Session Closing: {}", session.getId()); + SessionHandler handler = findSession(session); + + if (handler != null) { + handler.safeClose(WebSocketCloseReasons.NORMAL_CLOSE); + openSessions.remove(session); + } + } + + /** + * Called on a session error. + * @param session The platform specific session object. + * @param throwable The error that occurred. + */ + @OnError + public void onError(Session session, Throwable throwable) { + log.error("Session Error: {} {}", session.getId(), throwable.getMessage()); + SessionHandler handler = findSession(session); + + if (handler != null) { + handler.safeClose(WebSocketCloseReasons.INTERNAL_ERROR); + openSessions.remove(session); + } + } + + private SessionHandler findSession(Session wrappedSession) { + SessionHandler sessionHandler = openSessions.getOrDefault(wrappedSession, null); + + String message = "Unable to locate active session: " + wrappedSession.getId(); + if (sessionHandler == null) { + log.error(message); + } + return sessionHandler; + } + + protected SessionHandler createSessionHandler(Session session) { + String apiVersion = session.getRequestParameterMap().getOrDefault("ApiVersion", + List.of(NO_VERSION)).get(0); + + User user = userFactory.create(session); + + return new SessionHandler(session, elide.getDataStore(), elide, apis.get(apiVersion), + connectTimeoutMs, maxSubscriptions, + ConnectionInfo.builder() + .user(user) + .baseUrl(session.getRequestURI().getPath()) + .parameters(session.getRequestParameterMap()) + .getApiVersion(apiVersion).build(), + sendPingOnSubscribe, + verboseErrors, + executorService); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/AbstractProtocolMessage.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/AbstractProtocolMessage.java new file mode 100644 index 0000000000..8550b8eb9a --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/AbstractProtocolMessage.java @@ -0,0 +1,22 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Base class of all graphql-ws protocol messages. + */ +@AllArgsConstructor +@Getter +public abstract class AbstractProtocolMessage { + + @JsonProperty(required = true) + final MessageType type; +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/AbstractProtocolMessageWithID.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/AbstractProtocolMessageWithID.java new file mode 100644 index 0000000000..0def79d13d --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/AbstractProtocolMessageWithID.java @@ -0,0 +1,25 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +/** + * Base class of all graphql-ws messages that take an ID field. + */ +@Getter +public abstract class AbstractProtocolMessageWithID extends AbstractProtocolMessage { + + @JsonProperty(required = true) + final String id; + + public AbstractProtocolMessageWithID(String id, MessageType type) { + super(type); + this.id = id; + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Complete.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Complete.java new file mode 100644 index 0000000000..32afd67cbf --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Complete.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Builder; + +/** + * Send on subscription completion (bidirectional). + */ +@JsonPropertyOrder({"type", "id"}) +public class Complete extends AbstractProtocolMessageWithID { + + @Builder + @JsonCreator + public Complete( + @JsonProperty(value = "id", required = true) String id + ) { + super(id, MessageType.COMPLETE); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/ConnectionAck.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/ConnectionAck.java new file mode 100644 index 0000000000..7893b70d48 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/ConnectionAck.java @@ -0,0 +1,16 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +/** + * Acknowledge an incoming connection (server to client). + */ +public class ConnectionAck extends AbstractProtocolMessage { + public ConnectionAck() { + super(MessageType.CONNECTION_ACK); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/ConnectionInit.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/ConnectionInit.java new file mode 100644 index 0000000000..9ea4edac8f --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/ConnectionInit.java @@ -0,0 +1,27 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +import lombok.EqualsAndHashCode; +import lombok.Value; + +import java.util.HashMap; +import java.util.Map; + +/** + * Start an incoming connection (client to server). + */ +@Value +@EqualsAndHashCode(callSuper = true) +public class ConnectionInit extends AbstractProtocolMessage { + //Will contain authentication credentials. + Map payload = new HashMap<>(); + + public ConnectionInit() { + super(MessageType.CONNECTION_INIT); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Error.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Error.java new file mode 100644 index 0000000000..b4bc3b09c7 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Error.java @@ -0,0 +1,40 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +import com.yahoo.elide.graphql.GraphQLErrorDeserializer; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import graphql.GraphQLError; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Value; + +/** + * Error occurred during setup of the subscription (server to client). + */ +@Value +@EqualsAndHashCode(callSuper = true) +@JsonPropertyOrder({ "type", "id", "payload"}) +public class Error extends AbstractProtocolMessageWithID { + + @JsonProperty(required = true) + @JsonDeserialize(contentAs = GraphQLError.class, contentUsing = GraphQLErrorDeserializer.class) + GraphQLError[] payload; + + @Builder + @JsonCreator + public Error( + @JsonProperty(value = "id", required = true) String id, + @JsonProperty(value = "payload", required = true) GraphQLError[] payload + ) { + super(id, MessageType.ERROR); + this.payload = payload; + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/MessageType.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/MessageType.java new file mode 100644 index 0000000000..b2e3dd441e --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/MessageType.java @@ -0,0 +1,34 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * All the message names of the graphql-ws protocol. + */ +public enum MessageType { + CONNECTION_INIT("connection_init"), + CONNECTION_ACK("connection_ack"), + PING("ping"), + PONG("pong"), + SUBSCRIBE("subscribe"), + NEXT("next"), + ERROR("error"), + COMPLETE("complete"); + + private String name; + + @JsonValue + public String getName() { + return name; + } + + MessageType(String name) { + this.name = name; + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Next.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Next.java new file mode 100644 index 0000000000..1c253819b1 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Next.java @@ -0,0 +1,43 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +import com.yahoo.elide.graphql.ExecutionResultDeserializer; +import com.yahoo.elide.graphql.ExecutionResultSerializer; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import graphql.ExecutionResult; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Value; + +/** + * Next subscription message (server to client). + */ +@Value +@EqualsAndHashCode(callSuper = true) +@JsonPropertyOrder({ "type", "id", "payload"}) +public class Next extends AbstractProtocolMessageWithID { + @JsonProperty(required = true) + @JsonDeserialize(as = ExecutionResult.class, using = ExecutionResultDeserializer.class) + @JsonSerialize(as = ExecutionResult.class, using = ExecutionResultSerializer.class) + ExecutionResult payload; + + @Builder + @JsonCreator + public Next( + @JsonProperty(value = "id", required = true) String id, + @JsonProperty(value = "payload", required = true) ExecutionResult result + ) { + super(id, MessageType.NEXT); + + this.payload = result; + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Ping.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Ping.java new file mode 100644 index 0000000000..275795cea8 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Ping.java @@ -0,0 +1,16 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +/** + * Ping/Pong telemetry messaging (bidirectional). + */ +public class Ping extends AbstractProtocolMessage { + public Ping() { + super(MessageType.PING); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Pong.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Pong.java new file mode 100644 index 0000000000..961e439ac3 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Pong.java @@ -0,0 +1,16 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +/** + * Ping/Pong telemetry messaging (bidirectional). + */ +public class Pong extends AbstractProtocolMessage { + public Pong() { + super(MessageType.PONG); + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Subscribe.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Subscribe.java new file mode 100644 index 0000000000..825a1b5216 --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/Subscribe.java @@ -0,0 +1,64 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Value; + +import java.util.HashMap; +import java.util.Map; + +/** + * Create a subscription (client to server). + */ +@Value +@EqualsAndHashCode(callSuper = true) +@JsonPropertyOrder({"type", "id", "payload"}) +public class Subscribe extends AbstractProtocolMessageWithID { + + Payload payload; + + @Value + @EqualsAndHashCode + @JsonPropertyOrder({"operationName", "query", "variables"}) + public static class Payload { + String operationName; + + @JsonProperty(required = true) + String query; + + Map variables; + + @Builder + @JsonCreator + public Payload( + @JsonProperty("operationName") String operationName, + @JsonProperty(value = "query", required = true) String query, + @JsonProperty("variables") Map variables) { + this.operationName = operationName; + this.query = query; + if (variables == null) { + this.variables = new HashMap<>(); + } else { + this.variables = variables; + } + } + } + + @Builder + @JsonCreator + public Subscribe( + @JsonProperty(value = "id", required = true) String id, + @JsonProperty(value = "payload", required = true) Payload payload) { + super(id, MessageType.SUBSCRIBE); + this.payload = payload; + } +} diff --git a/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/WebSocketCloseReasons.java b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/WebSocketCloseReasons.java new file mode 100644 index 0000000000..a003737b3b --- /dev/null +++ b/elide-graphql/src/main/java/com/yahoo/elide/graphql/subscriptions/websocket/protocol/WebSocketCloseReasons.java @@ -0,0 +1,68 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.websocket.protocol; + +import lombok.Getter; + +import javax.websocket.CloseReason; + +/** + * Reasons the server will disconnect the web socket. + * See (graphql-ws) protocol. + */ +public class WebSocketCloseReasons { + public enum CloseCode { + CONNECTION_TIMEOUT(4408), + MULTIPLE_INIT(4429), + UNAUTHORIZED(4401), + INVALID_MESSAGE(4400), + MAX_SUBSCRIPTIONS(4300), //This is our own message (not part of the protocol) + DUPLICATE_ID(4409); + + @Getter + private int code; + + CloseCode(int code) { + this.code = code; + } + + public CloseReason toReason(String reason) { + return new CloseReason(createCloseCode(code), reason); + } + + } + + public static final CloseReason CONNECTION_TIMEOUT = + CloseCode.CONNECTION_TIMEOUT.toReason("Connection initialisation timeout"); + + public static final CloseReason MULTIPLE_INIT = + CloseCode.MULTIPLE_INIT.toReason("Too many initialisation requests"); + + public static final CloseReason UNAUTHORIZED = + CloseCode.UNAUTHORIZED.toReason("Unauthorized"); + + public static final CloseReason INVALID_MESSAGE = + CloseCode.INVALID_MESSAGE.toReason("Invalid message"); + + public static final CloseReason INTERNAL_ERROR = + new CloseReason(CloseReason.CloseCodes.UNEXPECTED_CONDITION, "Internal Error"); + + public static final CloseReason NORMAL_CLOSE = + new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "Normal Closure"); + + public static final CloseReason MAX_SUBSCRIPTIONS = + CloseCode.MAX_SUBSCRIPTIONS.toReason("Exceeded max subscriptions"); + + public static CloseReason.CloseCode createCloseCode(final int code) { + return new CloseReason.CloseCode() { + @Override + public int getCode() { + return code; + } + }; + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/ExecutionResultDeserializerTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/ExecutionResultDeserializerTest.java new file mode 100644 index 0000000000..c90a94ae0d --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/ExecutionResultDeserializerTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.language.SourceLocation; + +import java.util.Map; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ExecutionResultDeserializerTest { + private ObjectMapper mapper; + + @BeforeAll + public void init() { + mapper = new ObjectMapper(); + mapper.registerModule(new SimpleModule("ExecutionResult") + .addDeserializer(GraphQLError.class, new GraphQLErrorDeserializer()) + .addDeserializer(ExecutionResult.class, new ExecutionResultDeserializer()) + ); + } + + @Test + public void testDeserialization() throws Exception { + String resultText = "{\"data\":{\"book\":{\"id\":\"1\",\"title\":null}},\"errors\":[{\"message\":\"Exception while fetching data (/book/title) : Bad Request\",\"locations\":[{\"line\":1,\"column\":38}],\"path\":[\"book\",\"title\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}]}}"; + + ExecutionResult result = mapper.readValue(resultText, ExecutionResult.class); + + Map data = result.getData(); + Map book = (Map) data.get("book"); + + assertEquals("1", book.get("id")); + assertEquals(null, book.get("title")); + + GraphQLError error = result.getErrors().get(0); + + assertEquals(1, error.getLocations().size()); + assertEquals(new SourceLocation(1, 38), error.getLocations().get(0)); + assertEquals(2, error.getPath().size()); + assertEquals("book", error.getPath().get(0)); + assertEquals("title", error.getPath().get(1)); + assertEquals("Exception while fetching data (/book/title) : Bad Request", error.getMessage()); + } + + @Test + public void testDeserializationWithMissingData() throws Exception { + String resultText = "{\"errors\":[{\"message\":\"Exception while fetching data (/book/title) : Bad Request\",\"locations\":[{\"line\":1,\"column\":38}],\"path\":[\"book\",\"title\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}]}}"; + + ExecutionResult result = mapper.readValue(resultText, ExecutionResult.class); + + assertNull(result.getData()); + + GraphQLError error = result.getErrors().get(0); + + assertEquals(1, error.getLocations().size()); + assertEquals(new SourceLocation(1, 38), error.getLocations().get(0)); + assertEquals(2, error.getPath().size()); + assertEquals("book", error.getPath().get(0)); + assertEquals("title", error.getPath().get(1)); + assertEquals("Exception while fetching data (/book/title) : Bad Request", error.getMessage()); + } + + @Test + public void testDeserializationWithMissingErrors() throws Exception { + String resultText = "{\"data\":{\"book\":{\"id\":\"1\",\"title\":null}}}"; + + ExecutionResult result = mapper.readValue(resultText, ExecutionResult.class); + + Map data = result.getData(); + Map book = (Map) data.get("book"); + + assertEquals("1", book.get("id")); + assertEquals(null, book.get("title")); + assertTrue(result.getErrors().isEmpty()); + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherDeleteTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherDeleteTest.java index 7f8146a610..411874ef49 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherDeleteTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherDeleteTest.java @@ -12,7 +12,7 @@ */ public class FetcherDeleteTest extends PersistentResourceFetcherTest { @Test - public void testRootBadInput() { + public void testRootBadInput() throws Exception { String graphQLRequest = "mutation { " + "author(op:DELETE) { " + "edges { node { id } } " @@ -41,7 +41,7 @@ public void testRootIdNoData() throws Exception { } @Test - public void testRootIdWithBadData() { + public void testRootIdWithBadData() throws Exception { String graphQLRequest = "mutation { " + "author(op:DELETE, ids: [\"1\"], data: {id: \"2\"}) { " + "edges { node { id } } " @@ -70,7 +70,7 @@ public void testRootCollection() throws Exception { } @Test - public void testNestedBadInput() { + public void testNestedBadInput() throws Exception { String graphQLRequest = "mutation { " + "author(id: \"1\") { " + "books(op:DELETE) { " diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherFetchTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherFetchTest.java index 8a4fb864ad..2471869fcf 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherFetchTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherFetchTest.java @@ -7,9 +7,8 @@ package com.yahoo.elide.graphql; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.ElideResponse; import org.junit.jupiter.api.Test; -import graphql.ExecutionResult; - import java.util.HashMap; /** @@ -38,6 +37,22 @@ public void testMutationInQueryThrowsError() throws Exception { assertQueryFailsWith(query, "Exception while fetching data (/book) : Data model writes are only allowed in mutations"); } + @Test + public void testSubscriptionThrowsError() throws Exception { + String query = "subscription {\n" + + " book(ids: [\"1\"]) {\n" + + " edges {\n" + + " node {\n" + + " id\n" + + " title\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + + assertQueryFailsWith(query, "Schema is not configured for subscriptions."); + } + @Test public void testRootSingle() throws Exception { runComparisonTest("rootSingle"); @@ -263,7 +278,16 @@ public void testAliasPartialQueryAmbiguous() throws Exception { } @Test - public void testSchemaIntrospection() { + public void testFederationServiceIntrospection() throws Exception { + String graphQLRequest = "{ _service { sdl }}"; + + ElideResponse response = runGraphQLRequest(graphQLRequest, new HashMap<>()); + + assertTrue(! response.getBody().contains("errors")); + } + + @Test + public void testSchemaIntrospection() throws Exception { String graphQLRequest = "{" + "__schema {" + "types {" @@ -272,13 +296,13 @@ public void testSchemaIntrospection() { + "}" + "}"; - ExecutionResult result = runGraphQLRequest(graphQLRequest, new HashMap<>()); + ElideResponse response = runGraphQLRequest(graphQLRequest, new HashMap<>()); - assertTrue(result.getErrors().isEmpty()); + assertTrue(! response.getBody().contains("errors")); } @Test - public void testTypeIntrospection() { + public void testTypeIntrospection() throws Exception { String graphQLRequest = "{" + "__type(name: \"Author\") {" + " name" @@ -288,9 +312,10 @@ public void testTypeIntrospection() { + " }" + "}" + "}"; - ExecutionResult result = runGraphQLRequest(graphQLRequest, new HashMap<>()); - assertTrue(result.getErrors().isEmpty()); + ElideResponse response = runGraphQLRequest(graphQLRequest, new HashMap<>()); + + assertTrue(! response.getBody().contains("errors")); } @Override diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherRemoveTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherRemoveTest.java index c4adac8d66..9eb194e088 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherRemoveTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherRemoveTest.java @@ -12,7 +12,7 @@ */ public class FetcherRemoveTest extends PersistentResourceFetcherTest { @Test - public void testRootBadInput() { + public void testRootBadInput() throws Exception { String graphQLRequest = "mutation { " + "author(op:REMOVE) { " + "edges { node { id } } " @@ -23,7 +23,7 @@ public void testRootBadInput() { @Test - public void testRootIdWithBadData() { + public void testRootIdWithBadData() throws Exception { String graphQLRequest = "mutation { " + "author(op:REMOVE, ids: [\"1\"], data: {id: \"2\"}) { " + "edges { node { id } } " @@ -43,7 +43,7 @@ public void testRootCollectionNothingToRemove() throws Exception { } @Test - public void testNestedBadInput() { + public void testNestedBadInput() throws Exception { String graphQLRequest = "mutation { " + "author(id: \"1\") { " + "books(op:REMOVE) { " diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherReplaceTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherReplaceTest.java index 326e65ef2e..5258026ad7 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherReplaceTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/FetcherReplaceTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEndpointTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEndpointTest.java index 17ed9d6679..b1dd651d16 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEndpointTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLEndpointTest.java @@ -22,7 +22,6 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; - import com.yahoo.elide.Elide; import com.yahoo.elide.ElideSettingsBuilder; import com.yahoo.elide.core.audit.AuditLogger; @@ -135,7 +134,10 @@ public void setupTest() throws Exception { .withEntityDictionary(EntityDictionary.builder().checks(checkMappings).build()) .withAuditLogger(audit) .build()) + ); + + elide.doScans(); endpoint = new GraphQLEndpoint(elide); DataStoreTransaction tx = inMemoryStore.beginTransaction(); diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLErrorDeserializerTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLErrorDeserializerTest.java new file mode 100644 index 0000000000..8f5d0559b0 --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLErrorDeserializerTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import graphql.GraphQLError; +import graphql.language.SourceLocation; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class GraphQLErrorDeserializerTest { + private ObjectMapper mapper; + + @BeforeAll + public void init() { + mapper = new ObjectMapper(); + mapper.registerModule(new SimpleModule("GraphQLError") + .addDeserializer(GraphQLError.class, new GraphQLErrorDeserializer())); + + } + + @Test + public void testDeserialization() throws Exception { + String errorText = "{\"message\":\"Exception while fetching data (/book/title) : Bad Request\",\"locations\":[{\"line\":1,\"column\":38}],\"path\":[\"book\",\"title\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}"; + + GraphQLError error = mapper.readValue(errorText, GraphQLError.class); + + assertEquals(1, error.getLocations().size()); + assertEquals(new SourceLocation(1, 38), error.getLocations().get(0)); + assertEquals(2, error.getPath().size()); + assertEquals("book", error.getPath().get(0)); + assertEquals("title", error.getPath().get(1)); + assertEquals("Exception while fetching data (/book/title) : Bad Request", error.getMessage()); + } + + @Test + public void testDeserializationArray() throws Exception { + String errorText = "[{\"message\":\"Exception while fetching data (/book/title) : Bad Request\",\"locations\":[{\"line\":1,\"column\":38}],\"path\":[\"book\",\"title\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}]"; + + GraphQLError[] error = mapper.readValue(errorText, GraphQLError[].class); + + assertEquals(1, error[0].getLocations().size()); + assertEquals(new SourceLocation(1, 38), error[0].getLocations().get(0)); + assertEquals(2, error[0].getPath().size()); + assertEquals("book", error[0].getPath().get(0)); + assertEquals("title", error[0].getPath().get(1)); + assertEquals("Exception while fetching data (/book/title) : Bad Request", error[0].getMessage()); + } + + @Test + public void testDeserializationWithMissingPath() throws Exception { + String errorText = "{\"message\":\"Exception while fetching data (/book/title) : Bad Request\",\"locations\":[{\"line\":1,\"column\":38}]}"; + + GraphQLError error = mapper.readValue(errorText, GraphQLError.class); + + assertEquals(1, error.getLocations().size()); + assertEquals(new SourceLocation(1, 38), error.getLocations().get(0)); + assertNull(error.getPath()); + assertEquals("Exception while fetching data (/book/title) : Bad Request", error.getMessage()); + } + + @Test + public void testDeserializationWithMissingMessage() throws Exception { + String errorText = "{\"locations\":[{\"line\":1,\"column\":38}],\"path\":[\"book\",\"title\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}"; + + GraphQLError error = mapper.readValue(errorText, GraphQLError.class); + + assertEquals(1, error.getLocations().size()); + assertEquals(new SourceLocation(1, 38), error.getLocations().get(0)); + assertEquals(2, error.getPath().size()); + assertEquals("book", error.getPath().get(0)); + assertEquals("title", error.getPath().get(1)); + assertNull(error.getMessage()); + } + + @Test + public void testDeserializationWithMissingLocation() throws Exception { + String errorText = "{\"message\":\"Exception while fetching data (/book/title) : Bad Request\",\"path\":[\"book\",\"title\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}"; + + GraphQLError error = mapper.readValue(errorText, GraphQLError.class); + + assertNull(error.getLocations()); + assertEquals(2, error.getPath().size()); + assertEquals("book", error.getPath().get(0)); + assertEquals("title", error.getPath().get(1)); + assertEquals("Exception while fetching data (/book/title) : Bad Request", error.getMessage()); + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLScalarsTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLScalarsTest.java index 74f742520a..52e11b667c 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLScalarsTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLScalarsTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLTest.java index 6c576ada1d..22ea49fd7f 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/GraphQLTest.java @@ -6,8 +6,12 @@ package com.yahoo.elide.graphql; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import com.yahoo.elide.core.dictionary.ArgumentType; import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.Injector; import com.yahoo.elide.core.security.checks.Check; import com.yahoo.elide.core.type.ClassType; import example.Address; @@ -25,12 +29,17 @@ */ public abstract class GraphQLTest { protected EntityDictionary dictionary; + protected Injector injector; public GraphQLTest() { Map> checks = new HashMap<>(); checks.put("Prefab.Role.All", com.yahoo.elide.core.security.checks.prefab.Role.ALL.class); + injector = mock(Injector.class); + when(injector.instantiate(any())).thenCallRealMethod(); - dictionary = EntityDictionary.builder().checks(checks).build(); + dictionary = EntityDictionary.builder() + .injector(injector) + .checks(checks).build(); dictionary.bindEntity(Book.class); dictionary.bindEntity(Author.class); diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/LifecycleHookTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/LifecycleHookTest.java new file mode 100644 index 0000000000..90f45c1602 --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/LifecycleHookTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2017, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import example.Job; +import org.junit.jupiter.api.Test; +import hooks.JobLifeCycleHook; + +/** + * Test lifecycle hooks. + */ +public class LifecycleHookTest extends PersistentResourceFetcherTest { + + protected JobLifeCycleHook.JobService jobService = mock(JobLifeCycleHook.JobService.class); + + @Test + public void testCreate() throws Exception { + String query = "mutation {\n" + + " job(op: UPSERT, data: {id: 1} ) {\n" + + " edges {\n" + + " node {\n" + + " id\n" + + " status\n" + + " result\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + + assertQueryEquals(query, "{\"job\":{\"edges\":[{\"node\":{\"id\":\"1\",\"status\":1,\"result\":\"Pending\"}}]}}"); + } + + @Test + public void testUpdate() throws Exception { + testCreate(); + + String query = "mutation {\n" + + " job(op: UPDATE, data: { id: \"1\", result: \"Done\" } ) {\n" + + " edges {\n" + + " node {\n" + + " id\n" + + " status\n" + + " result\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + + assertQueryEquals(query, + "{\"job\":{\"edges\":[{\"node\":{\"id\":\"1\",\"status\":2, \"result\":\"Done\"}}]}}"); + } + + @Test + public void testDelete() throws Exception { + testCreate(); + + String query = "mutation {\n" + + " job(op: DELETE, ids: [\"1\"]) {\n" + + " edges {\n" + + " node {\n" + + " id\n" + + " status\n" + + " }\n" + + " }\n" + + " }\n" + + "}"; + + assertQueryEquals(query, "{\"job\":{\"edges\":[]}}"); + verify(jobService, times(1)).jobDeleted(any(Job.class)); + } + + @Override + protected void initializeMocks() { + super.initializeMocks(); + doAnswer(invocation -> { + Object[] args = invocation.getArguments(); + Object entity = args[0]; + + ((JobLifeCycleHook) entity).setJobService(jobService); + + //void method so return null + return null; + }).when(injector).inject(any(JobLifeCycleHook.class)); + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java index 7d843a46a7..4f43a10b3d 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/ModelBuilderTest.java @@ -8,11 +8,14 @@ import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; import static graphql.Assert.assertNotNull; +import static graphql.scalars.ExtendedScalars.GraphQLBigInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; + +import com.yahoo.elide.ElideSettings; import com.yahoo.elide.core.dictionary.ArgumentType; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.request.Sorting; @@ -25,6 +28,7 @@ import example.Publisher; import org.junit.jupiter.api.Test; import graphql.Scalars; +import graphql.scalars.java.JavaPrimitives; import graphql.schema.DataFetcher; import graphql.schema.GraphQLEnumType; import graphql.schema.GraphQLEnumValueDefinition; @@ -100,8 +104,10 @@ public ModelBuilderTest() { @Test public void testInternalModelConflict() { DataFetcher fetcher = mock(DataFetcher.class); + ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, - new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), fetcher, NO_VERSION); + new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), + settings, fetcher, NO_VERSION); GraphQLSchema schema = builder.build(); @@ -122,8 +128,10 @@ public void testInternalModelConflict() { @Test public void testPageInfoObject() { DataFetcher fetcher = mock(DataFetcher.class); + ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, - new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), fetcher, NO_VERSION); + new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), + settings, fetcher, NO_VERSION); GraphQLSchema schema = builder.build(); @@ -134,8 +142,10 @@ public void testPageInfoObject() { @Test public void testRelationshipParameters() { DataFetcher fetcher = mock(DataFetcher.class); + ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, - new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), fetcher, NO_VERSION); + new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), + settings, fetcher, NO_VERSION); GraphQLSchema schema = builder.build(); GraphQLObjectType root = schema.getQueryType(); @@ -171,8 +181,10 @@ public void testRelationshipParameters() { @Test public void testBuild() { DataFetcher fetcher = mock(DataFetcher.class); + ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, - new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), fetcher, NO_VERSION); + new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), + settings, fetcher, NO_VERSION); GraphQLSchema schema = builder.build(); @@ -203,8 +215,8 @@ public void testBuild() { assertEquals(Scalars.GraphQLString, bookType.getFieldDefinition(FIELD_TITLE).getType()); assertEquals(Scalars.GraphQLString, bookType.getFieldDefinition(FIELD_GENRE).getType()); assertEquals(Scalars.GraphQLString, bookType.getFieldDefinition(FIELD_LANGUAGE).getType()); - assertEquals(Scalars.GraphQLInt, bookType.getFieldDefinition(FIELD_PUBLISH_DATE).getType()); - assertEquals(Scalars.GraphQLFloat, bookType.getFieldDefinition(FIELD_WEIGHT_LBS).getType()); + assertEquals(GraphQLBigInteger, bookType.getFieldDefinition(FIELD_PUBLISH_DATE).getType()); + assertEquals(JavaPrimitives.GraphQLBigDecimal, bookType.getFieldDefinition(FIELD_WEIGHT_LBS).getType()); GraphQLObjectType addressType = (GraphQLObjectType) authorType.getFieldDefinition("homeAddress").getType(); assertEquals(Scalars.GraphQLString, addressType.getFieldDefinition("street1").getType()); @@ -230,7 +242,7 @@ public void testBuild() { assertEquals(Scalars.GraphQLString, bookInputType.getField(FIELD_TITLE).getType()); assertEquals(Scalars.GraphQLString, bookInputType.getField(FIELD_GENRE).getType()); assertEquals(Scalars.GraphQLString, bookInputType.getField(FIELD_LANGUAGE).getType()); - assertEquals(Scalars.GraphQLInt, bookInputType.getField(FIELD_PUBLISH_DATE).getType()); + assertEquals(GraphQLBigInteger, bookInputType.getField(FIELD_PUBLISH_DATE).getType()); GraphQLList authorsInputType = (GraphQLList) bookInputType.getField(FIELD_AUTHORS).getType(); assertEquals(authorInputType, authorsInputType.getWrappedType()); @@ -249,8 +261,10 @@ public void checkAttributeArguments() { dictionary.addArgumentsToAttribute(ClassType.of(Book.class), FIELD_PUBLISH_DATE, arguments); DataFetcher fetcher = mock(DataFetcher.class); + ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, - new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), fetcher, NO_VERSION); + new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), + settings, fetcher, NO_VERSION); GraphQLSchema schema = builder.build(); @@ -268,8 +282,10 @@ public void checkModelArguments() { dictionary.addArgumentToEntity(ClassType.of(Author.class), new ArgumentType("filterAuthor", ClassType.STRING_TYPE)); DataFetcher fetcher = mock(DataFetcher.class); + ElideSettings settings = mock(ElideSettings.class); ModelBuilder builder = new ModelBuilder(dictionary, - new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), fetcher, NO_VERSION); + new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup), + settings, fetcher, NO_VERSION); GraphQLSchema schema = builder.build(); diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/PersistentResourceFetcherTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/PersistentResourceFetcherTest.java index 3ca156092f..ff5974ce68 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/PersistentResourceFetcherTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/PersistentResourceFetcherTest.java @@ -7,21 +7,25 @@ import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideResponse; import com.yahoo.elide.ElideSettings; import com.yahoo.elide.ElideSettingsBuilder; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.datastore.inmemory.HashMapDataStore; -import com.yahoo.elide.core.datastore.inmemory.InMemoryDataStore; import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.security.User; import com.yahoo.elide.core.utils.DefaultClassScanner; import com.yahoo.elide.core.utils.coerce.CoerceUtil; import com.yahoo.elide.graphql.parser.GraphQLEntityProjectionMaker; -import com.yahoo.elide.graphql.parser.GraphQLProjectionInfo; -import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; import example.Author; import example.Book; import example.Price; @@ -29,15 +33,13 @@ import example.Publisher; import org.apache.tools.ant.util.FileUtils; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.TestInstance; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import graphql.ExecutionInput; -import graphql.ExecutionResult; import graphql.GraphQL; import graphql.GraphQLError; -import graphql.execution.AsyncSerialExecutionStrategy; import java.io.IOException; import java.io.InputStream; @@ -53,52 +55,47 @@ import java.util.List; import java.util.Map; import java.util.TimeZone; -import java.util.UUID; import java.util.stream.Collectors; /** * Base functionality required to test the PersistentResourceFetcher. */ -@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) public abstract class PersistentResourceFetcherTest extends GraphQLTest { - protected GraphQL api; protected ObjectMapper mapper = new ObjectMapper(); + protected QueryRunner runner; private static final Logger LOG = LoggerFactory.getLogger(GraphQL.class); private final String baseUrl = "http://localhost:8080/graphql"; + protected User user = mock(User.class); protected HashMapDataStore hashMapDataStore; - protected InMemoryDataStore inMemoryDataStore; protected ElideSettings settings; - public PersistentResourceFetcherTest() { - RSQLFilterDialect filterDialect = new RSQLFilterDialect(dictionary); + @BeforeAll + public void initializeQueryRunner() { + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); - settings = new ElideSettingsBuilder(null) + hashMapDataStore = new HashMapDataStore(DefaultClassScanner.getInstance(), Author.class.getPackage()); + + settings = new ElideSettingsBuilder(hashMapDataStore) .withEntityDictionary(dictionary) .withJoinFilterDialect(filterDialect) .withSubqueryFilterDialect(filterDialect) + .withGraphQLFederation(true) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) .build(); settings.getSerdes().forEach(CoerceUtil::register); - hashMapDataStore = new HashMapDataStore(DefaultClassScanner.getInstance(), Author.class.getPackage()); - - inMemoryDataStore = new InMemoryDataStore( - new HashMapDataStore(DefaultClassScanner.getInstance(), Author.class.getPackage()) - ); - - inMemoryDataStore.populateEntityDictionary(dictionary); - NonEntityDictionary nonEntityDictionary = - new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup); - ModelBuilder builder = new ModelBuilder(dictionary, nonEntityDictionary, - new PersistentResourceFetcher(nonEntityDictionary), NO_VERSION); + initializeMocks(); + Elide elide = new Elide(settings); + elide.doScans(); - api = GraphQL.newGraphQL(builder.build()) - .queryExecutionStrategy(new AsyncSerialExecutionStrategy()) - .build(); + runner = new QueryRunner(elide, NO_VERSION); + } - initTestData(); + protected void initializeMocks() { + //NOOP; } @AfterEach @@ -108,7 +105,7 @@ public void clearTestData() { @BeforeEach public void initTestData() { - DataStoreTransaction tx = inMemoryDataStore.beginTransaction(); + DataStoreTransaction tx = hashMapDataStore.beginTransaction(); Publisher publisher1 = new Publisher(); publisher1.setId(1L); @@ -189,92 +186,46 @@ protected void assertQueryEquals(String graphQLRequest, String expectedResponse) assertQueryEquals(graphQLRequest, expectedResponse, Collections.emptyMap()); } - protected void assertQueryEquals(String graphQLRequest, String expectedResponse, Map variables) throws Exception { - boolean isMutation = graphQLRequest.startsWith("mutation"); - - DataStoreTransaction tx = inMemoryDataStore.beginTransaction(); - GraphQLProjectionInfo projectionInfo = - new GraphQLEntityProjectionMaker(settings, variables, NO_VERSION).make(graphQLRequest); - GraphQLRequestScope requestScope = new GraphQLRequestScope(baseUrl, tx, null, NO_VERSION, settings, projectionInfo, UUID.randomUUID(), null); + protected void assertQueryEquals(String graphQLRequest, String expectedResponse, Map variables) + throws Exception { + ElideResponse response = runGraphQLRequest(graphQLRequest, variables); - ExecutionInput executionInput = ExecutionInput.newExecutionInput() - .query(graphQLRequest) - .context(requestScope) - .variables(variables) - .build(); + JsonNode data = mapper.readTree(response.getBody()).get("data"); + assertNotNull(data); - ExecutionResult result = api.execute(executionInput); - // NOTE: We're forcing commit even in case of failures. GraphQLEndpoint tests should ensure we do not commit on - // failure. - if (isMutation) { - requestScope.saveOrCreateObjects(); - } - requestScope.getTransaction().commit(requestScope); - assertEquals(0, result.getErrors().size(), "Errors [" + errorsToString(result.getErrors()) + "]:"); - try { - LOG.info(mapper.writeValueAsString(result.getData())); - assertEquals( - mapper.readTree(expectedResponse), - mapper.readTree(mapper.writeValueAsString(result.getData())) - ); - } catch (JsonProcessingException e) { - fail("JSON parsing exception", e); - } + assertEquals( + mapper.readTree(expectedResponse), + mapper.readTree(data.toString()) + ); } protected void assertQueryFailsWith(String graphQLRequest, String expectedMessage) throws Exception { - boolean isMutation = graphQLRequest.startsWith("mutation"); - - DataStoreTransaction tx = inMemoryDataStore.beginTransaction(); - GraphQLProjectionInfo projectionInfo = new GraphQLEntityProjectionMaker(settings).make(graphQLRequest); - GraphQLRequestScope requestScope = new GraphQLRequestScope(baseUrl, tx, null, NO_VERSION, settings, projectionInfo, UUID.randomUUID(), null); + ElideResponse response = runGraphQLRequest(graphQLRequest, new HashMap<>()); - ExecutionInput executionInput = ExecutionInput.newExecutionInput() - .query(graphQLRequest) - .context(requestScope) - .build(); + JsonNode errors = mapper.readTree(response.getBody()).get("errors"); + assertNotNull(errors); + assertTrue(errors.size() > 0); + JsonNode message = errors.get(0).get("message"); + assertNotNull(message); - ExecutionResult result = api.execute(executionInput); - if (isMutation) { - requestScope.saveOrCreateObjects(); - } - requestScope.getTransaction().commit(requestScope); - assertNotEquals(result.getErrors().size(), 0, "Expected errors. Received none."); - try { - String message = result.getErrors().get(0).getMessage(); - LOG.info(mapper.writeValueAsString(result.getErrors())); - assertEquals(expectedMessage, message); - } catch (JsonProcessingException e) { - fail("JSON parsing exception", e); - } + assertEquals('"' + expectedMessage + '"', message.toString()); } - protected void assertQueryFails(String graphQLRequest) { - ExecutionResult result = runGraphQLRequest(graphQLRequest, new HashMap<>()); - - //debug for errors - LOG.debug("Errors = [" + errorsToString(result.getErrors()) + "]"); + protected void assertQueryFails(String graphQLRequest) throws IOException { + ElideResponse result = runGraphQLRequest(graphQLRequest, new HashMap<>()); - assertNotEquals(result.getErrors().size(), 0); + assertTrue(result.getBody().contains("errors")); } protected void assertParsingFails(String graphQLRequest) { assertThrows(Exception.class, () -> new GraphQLEntityProjectionMaker(settings).make(graphQLRequest)); } - protected ExecutionResult runGraphQLRequest(String graphQLRequest, Map variables) { - DataStoreTransaction tx = inMemoryDataStore.beginTransaction(); - GraphQLProjectionInfo projectionInfo = new GraphQLEntityProjectionMaker(settings).make(graphQLRequest); - GraphQLRequestScope requestScope = new GraphQLRequestScope(baseUrl, tx, null, NO_VERSION, settings, - projectionInfo, UUID.randomUUID(), null); + protected ElideResponse runGraphQLRequest(String graphQLRequest, Map variables) + throws IOException { + String requestWithEnvelope = toGraphQLQuery(graphQLRequest, variables); - ExecutionInput executionInput = ExecutionInput.newExecutionInput() - .query(graphQLRequest) - .context(requestScope) - .variables(variables) - .build(); - - return api.execute(executionInput); + return runner.run(baseUrl, requestWithEnvelope, user); } protected String errorsToString(List errors) { @@ -316,6 +267,15 @@ protected void runComparisonTest(String testName, EvaluationFunction evalFn) thr evalFn.evaluate(graphQLRequest, graphQLResponse); } + protected String toGraphQLQuery(String query, Map variables) throws IOException { + ObjectNode graphqlNode = JsonNodeFactory.instance.objectNode(); + graphqlNode.put("query", query); + if (variables != null) { + graphqlNode.set("variables", mapper.valueToTree(variables)); + } + return mapper.writeValueAsString(graphqlNode); + } + @FunctionalInterface protected interface EvaluationFunction { void evaluate(String graphQLRequest, String graphQLResponse) throws Exception; diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/QueryRunnerTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/QueryRunnerTest.java new file mode 100644 index 0000000000..696f941337 --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/QueryRunnerTest.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +public class QueryRunnerTest { + + @ParameterizedTest + @ValueSource(strings = { + "#abcd\nmutation", + "#abcd\n\nmutation", + " #abcd\n\nmutation", + "#abcd\n #befd\n mutation", + "mutation" + }) + public void testIsMutation(String input) { + assertTrue(QueryRunner.isMutation(input)); + } + + @ParameterizedTest + @ValueSource(strings = { + "#abcd\n #befd\n query", + "query", + "QUERY", + "MUTATION", + "" + }) + public void testIsNotMutation(String input) { + assertFalse(QueryRunner.isMutation(input)); + } + + @Test + public void testNullMutation() { + assertFalse(QueryRunner.isMutation(null)); + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionDataFetcherTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionDataFetcherTest.java new file mode 100644 index 0000000000..4f1f18e911 --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionDataFetcherTest.java @@ -0,0 +1,402 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.graphql.subscriptions; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.datastore.inmemory.InMemoryDataStore; +import com.yahoo.elide.core.dictionary.ArgumentType; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.graphql.GraphQLRequestScope; +import com.yahoo.elide.graphql.GraphQLTest; +import com.yahoo.elide.graphql.NonEntityDictionary; +import com.yahoo.elide.graphql.parser.GraphQLProjectionInfo; +import com.yahoo.elide.graphql.parser.SubscriptionEntityProjectionMaker; +import com.yahoo.elide.graphql.subscriptions.hooks.TopicType; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import example.Address; +import example.Author; +import example.Book; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.execution.AsyncSerialExecutionStrategy; +import graphql.execution.SubscriptionExecutionStrategy; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Base functionality required to test the PersistentResourceFetcher. + */ +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +public class SubscriptionDataFetcherTest extends GraphQLTest { + protected GraphQL api; + protected ObjectMapper mapper = new ObjectMapper(); + private static final Logger LOG = LoggerFactory.getLogger(GraphQL.class); + private final String baseUrl = "http://localhost:8080/graphql"; + + protected DataStore dataStore; + protected DataStoreTransaction dataStoreTransaction; + protected ElideSettings settings; + + public SubscriptionDataFetcherTest() { + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); + + dataStore = mock(DataStore.class); + dataStoreTransaction = mock(DataStoreTransaction.class); + + //This will be done by the JMS data store. + dictionary.addArgumentToEntity(ClassType.of(Book.class), ArgumentType + .builder() + .name("topic") + .type(ClassType.of(TopicType.class)) + .build()); + + dictionary.addArgumentToEntity(ClassType.of(Author.class), ArgumentType + .builder() + .name("topic") + .type(ClassType.of(TopicType.class)) + .build()); + + settings = new ElideSettingsBuilder(dataStore) + .withEntityDictionary(dictionary) + .withJoinFilterDialect(filterDialect) + .withSubqueryFilterDialect(filterDialect) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .build(); + + settings.getSerdes().forEach(CoerceUtil::register); + + NonEntityDictionary nonEntityDictionary = + new NonEntityDictionary(DefaultClassScanner.getInstance(), CoerceUtil::lookup); + + SubscriptionModelBuilder builder = new SubscriptionModelBuilder(dictionary, nonEntityDictionary, + new SubscriptionDataFetcher(nonEntityDictionary), NO_VERSION); + + api = GraphQL.newGraphQL(builder.build()) + .queryExecutionStrategy(new AsyncSerialExecutionStrategy()) + .subscriptionExecutionStrategy(new SubscriptionExecutionStrategy()) + .build(); + } + + @BeforeEach + public void resetMocks() { + reset(dataStore); + reset(dataStoreTransaction); + when(dataStore.beginTransaction()).thenReturn(dataStoreTransaction); + when(dataStore.beginReadTransaction()).thenReturn(dataStoreTransaction); + when(dataStoreTransaction.getAttribute(any(), any(), any())).thenCallRealMethod(); + when(dataStoreTransaction.getToManyRelation(any(), any(), any(), any())).thenCallRealMethod(); + } + + @Test + void testRootSubscription() { + Book book1 = new Book(); + book1.setTitle("Book 1"); + book1.setId(1); + + Book book2 = new Book(); + book2.setTitle("Book 2"); + book2.setId(2); + + when(dataStoreTransaction.loadObjects(any(), any())) + .thenReturn(new DataStoreIterableBuilder(List.of(book1, book2)).build()); + + List responses = List.of( + "{\"book\":{\"id\":\"1\",\"title\":\"Book 1\"}}", + "{\"book\":{\"id\":\"2\",\"title\":\"Book 2\"}}" + ); + + String graphQLRequest = "subscription {book(topic: ADDED) {id title}}"; + + assertSubscriptionEquals(graphQLRequest, responses); + } + + @Test + void testRootNonSchemaQuery() { + Book book1 = new Book(); + book1.setTitle("Book 1"); + book1.setId(1); + + Book book2 = new Book(); + book2.setTitle("Book 2"); + book2.setId(2); + + when(dataStoreTransaction.loadObjects(any(), any())) + .thenReturn(new DataStoreIterableBuilder(List.of(book1, book2)).build()); + + List responses = List.of("{\"book\":null}"); + List errors = List.of("QUERY not supported for subscription models"); + + String graphQLRequest = "query {book(topic: ADDED) {id title}}"; + + assertSubscriptionEquals(graphQLRequest, responses, errors); + } + + @Test + void testRootSubscriptionWithFilter() { + Book book1 = new Book(); + book1.setTitle("Book 1"); + book1.setId(1); + + Book book2 = new Book(); + book2.setTitle("Book 2"); + book2.setId(2); + + when(dataStoreTransaction.loadObjects(any(), any())) + .thenReturn(new DataStoreIterableBuilder<>(List.of(book1, book2)).allInMemory().build()); + + List responses = List.of( + "{\"book\":{\"id\":\"1\",\"title\":\"Book 1\"}}" + ); + + String graphQLRequest = "subscription {book(topic: ADDED, filter: \"title==*1*\") {id title}}"; + + assertSubscriptionEquals(graphQLRequest, responses); + } + + @Test + void testComplexAttribute() { + Author author1 = new Author(); + author1.setId(1L); + author1.setHomeAddress(new Address()); + + Author author2 = new Author(); + author2.setId(2L); + Address address = new Address(); + address.setStreet1("123"); + address.setStreet2("XYZ"); + author2.setHomeAddress(address); + + when(dataStoreTransaction.loadObjects(any(), any())) + .thenReturn(new DataStoreIterableBuilder(List.of(author1, author2)).build()); + + List responses = List.of( + "{\"author\":{\"id\":\"1\",\"homeAddress\":{\"street1\":null,\"street2\":null}}}", + "{\"author\":{\"id\":\"2\",\"homeAddress\":{\"street1\":\"123\",\"street2\":\"XYZ\"}}}" + ); + + String graphQLRequest = "subscription {author(topic: UPDATED) {id homeAddress { street1 street2 }}}"; + + assertSubscriptionEquals(graphQLRequest, responses); + } + + @Test + void testRelationshipSubscription() { + Book book1 = new Book(); + book1.setTitle("Book 1"); + book1.setId(1); + Author author1 = new Author(); + author1.setName("John Doe"); + + Author author2 = new Author(); + author1.setName("Jane Doe"); + book1.setAuthors(List.of(author1, author2)); + + Book book2 = new Book(); + book2.setTitle("Book 2"); + book2.setId(2); + + when(dataStoreTransaction.loadObjects(any(), any())) + .thenReturn(new DataStoreIterableBuilder(List.of(book1, book2)).build()); + + List responses = List.of( + "{\"bookAdded\":{\"id\":\"1\",\"title\":\"Book 1\",\"authors\":[{\"name\":\"Jane Doe\"},{\"name\":null}]}}", + "{\"bookAdded\":{\"id\":\"2\",\"title\":\"Book 2\",\"authors\":[]}}" + ); + + String graphQLRequest = "subscription {bookAdded: book(topic:ADDED) {id title authors { name }}}"; + + assertSubscriptionEquals(graphQLRequest, responses); + } + + @Test + void testSchemaSubscription() { + String graphQLRequest = + "{" + + "__schema {" + + "types {" + + " name" + + "}" + + "}" + + "}"; + + assertSubscriptionEquals(graphQLRequest, List.of("{\"__schema\":{\"types\":[{\"name\":\"Author\"},{\"name\":\"AuthorTopic\"},{\"name\":\"AuthorType\"},{\"name\":\"Book\"},{\"name\":\"BookTopic\"},{\"name\":\"Boolean\"},{\"name\":\"DeferredID\"},{\"name\":\"String\"},{\"name\":\"Subscription\"},{\"name\":\"__Directive\"},{\"name\":\"__DirectiveLocation\"},{\"name\":\"__EnumValue\"},{\"name\":\"__Field\"},{\"name\":\"__InputValue\"},{\"name\":\"__Schema\"},{\"name\":\"__Type\"},{\"name\":\"__TypeKind\"},{\"name\":\"address\"}]}}\n")); + } + + @Test + void testErrorInSubscriptionStream() { + Book book1 = new Book(); + book1.setTitle("Book 1"); + book1.setId(1); + + Book book2 = new Book(); + book2.setTitle("Book 2"); + book2.setId(2); + + reset(dataStoreTransaction); + when(dataStoreTransaction.getAttribute(any(), any(), any())).thenThrow(new BadRequestException("Bad Request")); + when(dataStoreTransaction.loadObjects(any(), any())) + .thenReturn(new DataStoreIterableBuilder(List.of(book1, book2)).build()); + + List responses = List.of( + "{\"book\":{\"id\":\"1\",\"title\":null}}", + "{\"book\":{\"id\":\"2\",\"title\":null}}" + ); + + List errors = List.of("Bad Request", "Bad Request"); + + String graphQLRequest = "subscription {book(topic: ADDED) {id title}}"; + + assertSubscriptionEquals(graphQLRequest, responses, errors); + } + + @Test + void testErrorBeforeStream() { + Book book1 = new Book(); + book1.setTitle("Book 1"); + book1.setId(1); + + Book book2 = new Book(); + book2.setTitle("Book 2"); + book2.setId(2); + + when(dataStoreTransaction.loadObjects(any(), any())).thenThrow(new BadRequestException("Bad Request")); + + List responses = List.of("null"); + List errors = List.of("Bad Request"); + + String graphQLRequest = "subscription {book(topic:ADDED) {id title}}"; + + assertSubscriptionEquals(graphQLRequest, responses, errors); + } + + protected void assertSubscriptionEquals(String graphQLRequest, List expectedResponses) { + assertSubscriptionEquals(graphQLRequest, expectedResponses, new ArrayList<>()); + } + + protected void assertSubscriptionEquals( + String graphQLRequest, + List expectedResponses, + List expectedErrors) { + List results = runSubscription(graphQLRequest); + + assertEquals(expectedResponses.size(), results.size()); + + for (int i = 0; i < expectedResponses.size(); i++) { + String expectedResponse = expectedResponses.get(i); + String expectedError = "[]"; + if (!expectedErrors.isEmpty()) { + expectedError = expectedErrors.get(i); + } + ExecutionResult actualResponse = results.get(i); + + try { + LOG.info(mapper.writeValueAsString(actualResponse)); + assertEquals( + mapper.readTree(expectedResponse), + mapper.readTree(mapper.writeValueAsString(actualResponse.getData())) + ); + + assertTrue(actualResponse.getErrors().toString().contains(expectedError)); + } catch (JsonProcessingException e) { + fail("JSON parsing exception", e); + } + } + } + + /** + * Run a subscription. + * @param request The subscription query. + * @return A discrete list of results returned from the subscription. + */ + protected List runSubscription(String request) { + InMemoryDataStore inMemoryDataStore = new InMemoryDataStore(dataStore); + DataStoreTransaction tx = inMemoryDataStore.beginTransaction(); + + GraphQLProjectionInfo projectionInfo = + new SubscriptionEntityProjectionMaker(settings, new HashMap<>(), NO_VERSION).make(request); + GraphQLRequestScope requestScope = new GraphQLRequestScope(baseUrl, tx, null, NO_VERSION, settings, projectionInfo, UUID.randomUUID(), null); + + ExecutionInput executionInput = ExecutionInput.newExecutionInput() + .query(request) + .localContext(requestScope) + .build(); + + ExecutionResult executionResult = api.execute(executionInput); + + if (! (executionResult.getData() instanceof Publisher)) { + return List.of(executionResult); + } + + Publisher resultPublisher = executionResult.getData(); + + requestScope.getTransaction().commit(requestScope); + + if (resultPublisher == null) { + return List.of(executionResult); + } + + List results = new ArrayList<>(); + AtomicReference subscriptionRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + + resultPublisher.subscribe(new Subscriber() { + @Override + public void onSubscribe(Subscription subscription) { + subscriptionRef.set(subscription); + subscription.request(1); + } + + @Override + public void onNext(ExecutionResult executionResult) { + results.add(executionResult); + subscriptionRef.get().request(1); + } + + @Override + public void onError(Throwable t) { + errorRef.set(t); + } + + @Override + public void onComplete() { + //NOOP + } + }); + + return results; + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionModelBuilderTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionModelBuilderTest.java index 1d04d4e804..e67feadd99 100644 --- a/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionModelBuilderTest.java +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionModelBuilderTest.java @@ -16,6 +16,7 @@ import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.utils.DefaultClassScanner; import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.graphql.GraphQLScalars; import com.yahoo.elide.graphql.NonEntityDictionary; import example.Address; import example.Author; @@ -26,6 +27,7 @@ import graphql.Scalars; import graphql.schema.DataFetcher; import graphql.schema.GraphQLEnumType; +import graphql.schema.GraphQLFieldDefinition; import graphql.schema.GraphQLList; import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLSchema; @@ -35,12 +37,10 @@ public class SubscriptionModelBuilderTest { private static final String FILTER = "filter"; - private static final String BOOK_ADDED = "bookAdded"; - private static final String BOOK_DELETED = "bookDeleted"; - private static final String BOOK_UPDATED = "bookUpdated"; - private static final String AUTHOR_ADDED = "authorAdded"; - private static final String AUTHOR_DELETED = "authorDeleted"; - private static final String AUTHOR_UPDATED = "authorUpdated"; + private static final String BOOK = "book"; + private static final String AUTHOR = "author"; + private static final String PREVIEW = "preview"; + private static final String TOPIC = "topic"; private static final String SUBSCRIPTION = "Subscription"; private static final String TYPE_BOOK = "Book"; @@ -74,27 +74,39 @@ public void testRootType() { GraphQLSchema schema = builder.build(); GraphQLObjectType subscriptionType = (GraphQLObjectType) schema.getType("Subscription"); + //Book Type GraphQLObjectType bookType = (GraphQLObjectType) schema.getType(TYPE_BOOK); - GraphQLObjectType authorType = (GraphQLObjectType) schema.getType(TYPE_AUTHOR); - assertEquals(bookType, subscriptionType.getFieldDefinition(BOOK_ADDED).getType()); - assertNotNull(subscriptionType.getFieldDefinition(BOOK_ADDED).getArgument(FILTER)); + GraphQLFieldDefinition bookField = subscriptionType.getFieldDefinition(BOOK); + GraphQLEnumType bookTopicType = (GraphQLEnumType) bookField.getArgument(TOPIC).getType(); + assertEquals("BookTopic", bookTopicType.getName()); + assertNotNull(bookTopicType.getValue("ADDED")); + assertNotNull(bookTopicType.getValue("UPDATED")); + assertNull(bookTopicType.getValue("DELETED")); + + assertEquals(bookType, subscriptionType.getFieldDefinition(BOOK).getType()); + assertNotNull(subscriptionType.getFieldDefinition(BOOK).getArgument(FILTER)); - assertNull(subscriptionType.getFieldDefinition(BOOK_DELETED)); + //Author Type + GraphQLObjectType authorType = (GraphQLObjectType) schema.getType(TYPE_AUTHOR); + assertEquals(authorType, subscriptionType.getFieldDefinition(AUTHOR).getType()); + assertNotNull(subscriptionType.getFieldDefinition(AUTHOR).getArgument(FILTER)); - assertEquals(bookType, subscriptionType.getFieldDefinition(BOOK_UPDATED).getType()); - assertNotNull(subscriptionType.getFieldDefinition(BOOK_UPDATED).getArgument(FILTER)); + GraphQLFieldDefinition authorField = subscriptionType.getFieldDefinition(AUTHOR); + GraphQLEnumType authorTopicType = (GraphQLEnumType) authorField.getArgument(TOPIC).getType(); + assertEquals("AuthorTopic", authorTopicType.getName()); + assertNotNull(authorTopicType.getValue("ADDED")); + assertNotNull(authorTopicType.getValue("UPDATED")); + assertNotNull(authorTopicType.getValue("DELETED")); - assertEquals(authorType, subscriptionType.getFieldDefinition(AUTHOR_ADDED).getType()); - assertNotNull(subscriptionType.getFieldDefinition(AUTHOR_ADDED).getArgument(FILTER)); - assertEquals(authorType, subscriptionType.getFieldDefinition(AUTHOR_DELETED).getType()); - assertNotNull(subscriptionType.getFieldDefinition(AUTHOR_DELETED).getArgument(FILTER)); - assertEquals(authorType, subscriptionType.getFieldDefinition(AUTHOR_UPDATED).getType()); - assertNotNull(subscriptionType.getFieldDefinition(AUTHOR_UPDATED).getArgument(FILTER)); + //Publisher Type + assertNull(subscriptionType.getFieldDefinition("publisher")); - assertNull(subscriptionType.getFieldDefinition("publisherAdded")); - assertNull(subscriptionType.getFieldDefinition("publisherDeleted")); - assertNull(subscriptionType.getFieldDefinition("publisherUpdated")); + //Preview Type (Custom Subscription) + GraphQLObjectType previewType = (GraphQLObjectType) schema.getType(TYPE_PREVIEW); + GraphQLFieldDefinition previewField = subscriptionType.getFieldDefinition(PREVIEW); + assertEquals(previewType, previewField.getType()); + assertNull(previewField.getArgument(TOPIC)); } @Test @@ -123,7 +135,7 @@ public void testModelTypes() { //Verify Book Fields assertEquals(Scalars.GraphQLString, bookType.getFieldDefinition(FIELD_TITLE).getType()); assertEquals(Scalars.GraphQLString, bookType.getFieldDefinition(FIELD_GENRE).getType()); - assertEquals(Scalars.GraphQLID, previewType.getFieldDefinition(FIELD_ID).getType()); + assertEquals(GraphQLScalars.GRAPHQL_DEFERRED_ID, previewType.getFieldDefinition(FIELD_ID).getType()); assertEquals(previewType, ((GraphQLList) bookType.getFieldDefinition("previews").getType()).getWrappedType()); assertEquals(authorType, @@ -136,7 +148,7 @@ public void testModelTypes() { assertNull(bookType.getFieldDefinition("publisher")); //Verify Author Fields - assertEquals(Scalars.GraphQLID, authorType.getFieldDefinition(FIELD_ID).getType()); + assertEquals(GraphQLScalars.GRAPHQL_DEFERRED_ID, authorType.getFieldDefinition(FIELD_ID).getType()); GraphQLObjectType addressType = (GraphQLObjectType) authorType.getFieldDefinition("homeAddress").getType(); assertEquals(Scalars.GraphQLString, addressType.getFieldDefinition("street1").getType()); assertEquals(Scalars.GraphQLString, addressType.getFieldDefinition("street2").getType()); @@ -149,8 +161,8 @@ public void testModelTypes() { assertNull(bookType.getFieldDefinition("books")); //Verify Preview Fields - assertEquals(Scalars.GraphQLID, previewType.getFieldDefinition(FIELD_ID).getType()); - assertEquals(1, previewType.getFieldDefinitions().size()); + assertEquals(GraphQLScalars.GRAPHQL_DEFERRED_ID, previewType.getFieldDefinition(FIELD_ID).getType()); + assertEquals(2, previewType.getFieldDefinitions().size()); } } diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionSerdeTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionSerdeTest.java new file mode 100644 index 0000000000..e783862cb8 --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionSerdeTest.java @@ -0,0 +1,106 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.yahoo.elide.core.utils.coerce.converters.ISO8601DateSerde; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; +import com.yahoo.elide.graphql.subscriptions.hooks.SubscriptionExclusionStrategy; +import com.yahoo.elide.graphql.subscriptions.hooks.SubscriptionFieldSerde; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.junit.jupiter.api.Test; + +import lombok.Data; + +import java.util.Date; +import java.util.TimeZone; +import javax.persistence.Id; + +public class SubscriptionSerdeTest { + + private Gson gson; + + public SubscriptionSerdeTest() { + ISO8601DateSerde serde = new ISO8601DateSerde("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")); + + gson = new GsonBuilder() + .addSerializationExclusionStrategy(new SubscriptionExclusionStrategy()) + .registerTypeAdapter(Date.class, new SubscriptionFieldSerde<>(serde)) + .serializeNulls().create(); + } + + @Data + public static class TestModel { + @Id + long id; + + @SubscriptionField + String stringField; + + @SubscriptionField + Date date; + + String notSerialized; + } + + @Test + public void testSerialization() { + TestModel testModel = new TestModel(); + testModel.setId(1); + testModel.setDate(new Date(0)); + testModel.setNotSerialized("should not be present"); + testModel.setStringField("foo"); + + String output = gson.toJson(testModel); + assertEquals("{\"id\":1,\"stringField\":\"foo\",\"date\":\"1970-01-01T00:00Z\"}", output); + } + + @Test + public void testSerializationWithNull() { + TestModel testModel = new TestModel(); + testModel.setId(1); + testModel.setDate(null); + testModel.setNotSerialized("should not be present"); + testModel.setStringField("foo"); + + String output = gson.toJson(testModel); + assertEquals("{\"id\":1,\"stringField\":\"foo\",\"date\":null}", output); + } + + @Test + public void testDeserialization() { + TestModel testModel = new TestModel(); + testModel.setId(1); + testModel.setDate(new Date(0)); + testModel.setNotSerialized("should not be present"); + testModel.setStringField("foo"); + + String output = gson.toJson(testModel); + + TestModel deserialized = gson.fromJson(output, TestModel.class); + + testModel.setNotSerialized(null); + assertEquals(testModel, deserialized); + } + + @Test + public void testDeserializationWithNull() { + TestModel testModel = new TestModel(); + testModel.setId(1); + testModel.setDate(null); + testModel.setNotSerialized("should not be present"); + testModel.setStringField("foo"); + + String output = gson.toJson(testModel); + + TestModel deserialized = gson.fromJson(output, TestModel.class); + + testModel.setNotSerialized(null); + assertEquals(testModel, deserialized); + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionWebSocketTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionWebSocketTest.java new file mode 100644 index 0000000000..60072e6da7 --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/SubscriptionWebSocketTest.java @@ -0,0 +1,491 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.graphql.subscriptions; + +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.CONNECTION_TIMEOUT; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.INVALID_MESSAGE; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.MULTIPLE_INIT; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.NORMAL_CLOSE; +import static com.yahoo.elide.graphql.subscriptions.websocket.protocol.WebSocketCloseReasons.UNAUTHORIZED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettings; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.dictionary.ArgumentType; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.graphql.ExecutionResultDeserializer; +import com.yahoo.elide.graphql.ExecutionResultSerializer; +import com.yahoo.elide.graphql.GraphQLErrorDeserializer; +import com.yahoo.elide.graphql.GraphQLErrorSerializer; +import com.yahoo.elide.graphql.GraphQLTest; +import com.yahoo.elide.graphql.subscriptions.hooks.TopicType; +import com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Complete; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.ConnectionInit; +import com.yahoo.elide.graphql.subscriptions.websocket.protocol.Subscribe; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.google.common.util.concurrent.MoreExecutors; +import example.Author; +import example.Book; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.mockito.ArgumentCaptor; +import graphql.ExecutionResult; +import graphql.GraphQLError; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.TimeZone; +import java.util.concurrent.ExecutorService; +import javax.websocket.CloseReason; +import javax.websocket.RemoteEndpoint; +import javax.websocket.Session; + +/** + * Base functionality required to test the PersistentResourceFetcher. + */ +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@Slf4j +public class SubscriptionWebSocketTest extends GraphQLTest { + protected ObjectMapper mapper = new ObjectMapper(); + + protected DataStore dataStore; + protected DataStoreTransaction dataStoreTransaction; + protected ElideSettings settings; + protected Session session; + protected RemoteEndpoint.Async remote; + protected Elide elide; + protected ExecutorService executorService = MoreExecutors.newDirectExecutorService(); + + public SubscriptionWebSocketTest() { + RSQLFilterDialect filterDialect = RSQLFilterDialect.builder().dictionary(dictionary).build(); + + //This will be done by the JMS data store. + dictionary.addArgumentToEntity(ClassType.of(Book.class), ArgumentType + .builder() + .name("topic") + .type(ClassType.of(TopicType.class)) + .build()); + + dictionary.addArgumentToEntity(ClassType.of(Author.class), ArgumentType + .builder() + .name("topic") + .type(ClassType.of(TopicType.class)) + .build()); + + dataStore = mock(DataStore.class); + dataStoreTransaction = mock(DataStoreTransaction.class); + session = mock(Session.class); + remote = mock(RemoteEndpoint.Async.class); + + settings = new ElideSettingsBuilder(dataStore) + .withEntityDictionary(dictionary) + .withJoinFilterDialect(filterDialect) + .withSubqueryFilterDialect(filterDialect) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .build(); + + elide = new Elide(settings); + + elide.getMapper().getObjectMapper().registerModule(new SimpleModule("Test") + .addSerializer(ExecutionResult.class, new ExecutionResultSerializer(new GraphQLErrorSerializer())) + .addSerializer(GraphQLError.class, new GraphQLErrorSerializer()) + .addDeserializer(ExecutionResult.class, new ExecutionResultDeserializer()) + .addDeserializer(GraphQLError.class, new GraphQLErrorDeserializer()) + ); + } + + @BeforeEach + public void resetMocks() throws Exception { + reset(dataStore); + reset(dataStoreTransaction); + reset(session); + when(session.getRequestURI()).thenReturn(new URI("http://localhost:1234/subscription")); + when(session.getAsyncRemote()).thenReturn(remote); + when(dataStore.beginTransaction()).thenReturn(dataStoreTransaction); + when(dataStore.beginReadTransaction()).thenReturn(dataStoreTransaction); + when(dataStoreTransaction.getAttribute(any(), any(), any())).thenCallRealMethod(); + when(dataStoreTransaction.getToManyRelation(any(), any(), any(), any())).thenCallRealMethod(); + } + + @Test + void testConnectionSetupAndTeardown() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + ConnectionInit init = new ConnectionInit(); + endpoint.onOpen(session); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + + endpoint.onClose(session); + + verify(remote, times(1)).sendText(message.capture()); + assertEquals("{\"type\":\"connection_ack\"}", message.getAllValues().get(0)); + + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(session, times(1)).close(closeReason.capture()); + assertEquals(NORMAL_CLOSE, closeReason.getValue()); + } + + @Test + void testMissingType() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + String invalid = "{ \"id\": 123 }"; + endpoint.onOpen(session); + endpoint.onMessage(session, invalid); + + verify(remote, never()).sendText(any()); + + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(session, times(1)).close(closeReason.capture()); + assertEquals(INVALID_MESSAGE, closeReason.getValue()); + } + + @Test + void testInvalidType() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + String invalid = "{ \"type\": \"foo\", \"id\": 123 }"; + endpoint.onOpen(session); + endpoint.onMessage(session, invalid); + + verify(remote, never()).sendText(any()); + + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(session, times(1)).close(closeReason.capture()); + assertEquals(INVALID_MESSAGE, closeReason.getValue()); + } + + @Test + void testInvalidJson() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + //Missing payload field + String invalid = "{ \"type\": \"subscribe\"}"; + ConnectionInit init = new ConnectionInit(); + endpoint.onOpen(session); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + endpoint.onMessage(session, invalid); + + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + + verify(remote, times(1)).sendText(message.capture()); + assertEquals("{\"type\":\"connection_ack\"}", message.getAllValues().get(0)); + + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(session, times(1)).close(closeReason.capture()); + assertEquals(INVALID_MESSAGE, closeReason.getValue()); + } + + @Test + void testConnectionTimeout() throws Exception { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .connectTimeoutMs(0).elide(elide).build(); + + endpoint.onOpen(session); + + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(session, timeout(1000).times(1)).close(closeReason.capture()); + assertEquals(CONNECTION_TIMEOUT, closeReason.getValue()); + } + + @Test + void testDoubleInit() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + ConnectionInit init = new ConnectionInit(); + endpoint.onOpen(session); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + + verify(remote, times(1)).sendText(message.capture()); + assertEquals("{\"type\":\"connection_ack\"}", message.getAllValues().get(0)); + + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(session, times(1)).close(closeReason.capture()); + assertEquals(MULTIPLE_INIT, closeReason.getValue()); + } + + @Test + void testSubscribeBeforeInit() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + endpoint.onOpen(session); + + Subscribe subscribe = Subscribe.builder() + .id("1") + .payload(Subscribe.Payload.builder() + .query("subscription {book(topic: ADDED) {id title}}") + .build()) + .build(); + + endpoint.onMessage(session, mapper.writeValueAsString(subscribe)); + + verify(remote, never()).sendText(any()); + + ArgumentCaptor closeReason = ArgumentCaptor.forClass(CloseReason.class); + verify(session, times(1)).close(closeReason.capture()); + assertEquals(UNAUTHORIZED, closeReason.getValue()); + } + + @Test + void testSubscribeUnsubscribeSubscribe() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + ConnectionInit init = new ConnectionInit(); + endpoint.onOpen(session); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + + Subscribe subscribe = Subscribe.builder() + .id("1") + .payload(Subscribe.Payload.builder() + .query("subscription {book(topic: ADDED) {id title}}") + .build()) + .build(); + + endpoint.onMessage(session, mapper.writeValueAsString(subscribe)); + + Complete complete = Complete.builder().id("1").build(); + + endpoint.onMessage(session, mapper.writeValueAsString(complete)); + endpoint.onMessage(session, mapper.writeValueAsString(subscribe)); + + List expected = List.of( + "{\"type\":\"connection_ack\"}", + "{\"type\":\"complete\",\"id\":\"1\"}", + "{\"type\":\"complete\",\"id\":\"1\"}" + ); + + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + verify(remote, times(3)).sendText(message.capture()); + assertEquals(expected, message.getAllValues()); + } + + @Test + void testErrorInStream() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + Book book1 = new Book(); + book1.setTitle("Book 1"); + book1.setId(1); + + Book book2 = new Book(); + book2.setTitle("Book 2"); + book2.setId(2); + + reset(dataStoreTransaction); + when(dataStoreTransaction.getAttribute(any(), any(), any())).thenThrow(new BadRequestException("Bad Request")); + when(dataStoreTransaction.loadObjects(any(), any())) + .thenReturn(new DataStoreIterableBuilder(List.of(book1, book2)).build()); + + ConnectionInit init = new ConnectionInit(); + endpoint.onOpen(session); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + + Subscribe subscribe = Subscribe.builder() + .id("1") + .payload(Subscribe.Payload.builder() + .query("subscription {book(topic: ADDED) {id title}}") + .build()) + .build(); + + endpoint.onMessage(session, mapper.writeValueAsString(subscribe)); + + List expected = List.of( + "{\"type\":\"connection_ack\"}", + "{\"type\":\"next\",\"id\":\"1\",\"payload\":{\"data\":{\"book\":{\"id\":\"1\",\"title\":null}},\"errors\":[{\"message\":\"Exception while fetching data (/book/title) : Bad Request\",\"locations\":[{\"line\":1,\"column\":38}],\"path\":[\"book\",\"title\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}]}}", + "{\"type\":\"next\",\"id\":\"1\",\"payload\":{\"data\":{\"book\":{\"id\":\"2\",\"title\":null}},\"errors\":[{\"message\":\"Exception while fetching data (/book/title) : Bad Request\",\"locations\":[{\"line\":1,\"column\":38}],\"path\":[\"book\",\"title\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}]}}", + "{\"type\":\"complete\",\"id\":\"1\"}" + ); + + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + verify(remote, times(4)).sendText(message.capture()); + assertEquals(expected, message.getAllValues()); + } + + @Test + void testErrorPriorToStream() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + reset(dataStoreTransaction); + when(dataStoreTransaction.loadObjects(any(), any())).thenThrow(new BadRequestException("Bad Request")); + + ConnectionInit init = new ConnectionInit(); + endpoint.onOpen(session); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + + Subscribe subscribe = Subscribe.builder() + .id("1") + .payload(Subscribe.Payload.builder() + .query("subscription {book(topic: ADDED) {id title}}") + .build()) + .build(); + + endpoint.onMessage(session, mapper.writeValueAsString(subscribe)); + + List expected = List.of( + "{\"type\":\"connection_ack\"}", + "{\"type\":\"next\",\"id\":\"1\",\"payload\":{\"data\":null,\"errors\":[{\"message\":\"Exception while fetching data (/book) : Bad Request\",\"locations\":[{\"line\":1,\"column\":15}],\"path\":[\"book\"],\"extensions\":{\"classification\":\"DataFetchingException\"}}]}}", + "{\"type\":\"complete\",\"id\":\"1\"}" + ); + + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + verify(remote, times(3)).sendText(message.capture()); + assertEquals(expected, message.getAllValues()); + } + + @Test + void testRootSubscription() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + Book book1 = new Book(); + book1.setTitle("Book 1"); + book1.setId(1); + + Book book2 = new Book(); + book2.setTitle("Book 2"); + book2.setId(2); + + when(dataStoreTransaction.loadObjects(any(), any())) + .thenReturn(new DataStoreIterableBuilder(List.of(book1, book2)).build()); + + ConnectionInit init = new ConnectionInit(); + endpoint.onOpen(session); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + + Subscribe subscribe = Subscribe.builder() + .id("1") + .payload(Subscribe.Payload.builder() + .query("subscription {book(topic: ADDED) {id title}}") + .build()) + .build(); + + endpoint.onMessage(session, mapper.writeValueAsString(subscribe)); + + List expected = List.of( + "{\"type\":\"connection_ack\"}", + "{\"type\":\"next\",\"id\":\"1\",\"payload\":{\"data\":{\"book\":{\"id\":\"1\",\"title\":\"Book 1\"}}}}", + "{\"type\":\"next\",\"id\":\"1\",\"payload\":{\"data\":{\"book\":{\"id\":\"2\",\"title\":\"Book 2\"}}}}", + "{\"type\":\"complete\",\"id\":\"1\"}" + ); + + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + verify(remote, times(4)).sendText(message.capture()); + assertEquals(expected, message.getAllValues()); + } + + @Test + void testSchemaQuery() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + String graphQLRequest = + "{" + + "__schema {" + + "types {" + + " name" + + "}" + + "}" + + "__type(name: \"Author\") {" + + " name" + + " fields {" + + " name" + + " type { name }" + + " }" + + "}" + + "}"; + + ConnectionInit init = new ConnectionInit(); + endpoint.onOpen(session); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + + Subscribe subscribe = Subscribe.builder() + .id("1") + .payload(Subscribe.Payload.builder() + .query(graphQLRequest) + .build()) + .build(); + + endpoint.onMessage(session, mapper.writeValueAsString(subscribe)); + + List expected = List.of( + "{\"type\":\"connection_ack\"}", + "{\"type\":\"next\",\"id\":\"1\",\"payload\":{\"data\":{\"__schema\":{\"types\":[{\"name\":\"Author\"},{\"name\":\"AuthorTopic\"},{\"name\":\"AuthorType\"},{\"name\":\"Book\"},{\"name\":\"BookTopic\"},{\"name\":\"Boolean\"},{\"name\":\"DeferredID\"},{\"name\":\"String\"},{\"name\":\"Subscription\"},{\"name\":\"__Directive\"},{\"name\":\"__DirectiveLocation\"},{\"name\":\"__EnumValue\"},{\"name\":\"__Field\"},{\"name\":\"__InputValue\"},{\"name\":\"__Schema\"},{\"name\":\"__Type\"},{\"name\":\"__TypeKind\"},{\"name\":\"address\"}]},\"__type\":{\"name\":\"Author\",\"fields\":[{\"name\":\"id\",\"type\":{\"name\":\"DeferredID\"}},{\"name\":\"homeAddress\",\"type\":{\"name\":\"address\"}},{\"name\":\"name\",\"type\":{\"name\":\"String\"}},{\"name\":\"type\",\"type\":{\"name\":\"AuthorType\"}}]}}}}", + "{\"type\":\"complete\",\"id\":\"1\"}" + ); + + ArgumentCaptor message = ArgumentCaptor.forClass(String.class); + verify(remote, times(3)).sendText(message.capture()); + assertEquals(expected, message.getAllValues()); + } + + @Test + void testActualComplete() throws IOException { + SubscriptionWebSocket endpoint = SubscriptionWebSocket.builder() + .executorService(executorService) + .elide(elide).build(); + + ConnectionInit init = new ConnectionInit(); + endpoint.onOpen(session); + endpoint.onMessage(session, mapper.writeValueAsString(init)); + + Subscribe subscribe = Subscribe.builder() + .id("1") + .payload(Subscribe.Payload.builder() + .query("subscription {book(topic: ADDED) {id title}}") + .build()) + .build(); + + endpoint.onMessage(session, mapper.writeValueAsString(subscribe)); + + String complete = "{\"id\":\"5d585eff-ed05-48c2-8af7-ad662930ba74\",\"type\":\"complete\"}"; + + endpoint.onMessage(session, complete); + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/hooks/NotifyTopicLifeCycleHookTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/hooks/NotifyTopicLifeCycleHookTest.java new file mode 100644 index 0000000000..b7da4786ed --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/hooks/NotifyTopicLifeCycleHookTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.hooks; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.PersistentResource; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.lifecycle.CRUDEvent; +import com.google.gson.GsonBuilder; +import example.Book; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; +import javax.jms.ConnectionFactory; +import javax.jms.Destination; +import javax.jms.JMSContext; +import javax.jms.JMSProducer; +import javax.jms.Topic; + +public class NotifyTopicLifeCycleHookTest { + + private ConnectionFactory connectionFactory = mock(ConnectionFactory.class); + private JMSContext context = mock(JMSContext.class); + private JMSProducer producer = mock(JMSProducer.class); + private RequestScope scope = mock(RequestScope.class); + + @BeforeEach + public void setup() { + EntityDictionary dictionary; + dictionary = EntityDictionary.builder().build(); + dictionary.bindEntity(Book.class); + + Topic destination = mock(Topic.class); + reset(scope); + reset(connectionFactory); + reset(producer); + when(connectionFactory.createContext()).thenReturn(context); + when(context.createProducer()).thenReturn(producer); + when(context.createTopic(any())).thenReturn(destination); + when(scope.getDictionary()).thenReturn(dictionary); + } + + @Test + public void testManagedModelNotification() { + + NotifyTopicLifeCycleHook bookHook = new NotifyTopicLifeCycleHook( + connectionFactory, + JMSContext::createProducer, new GsonBuilder().create()); + + Book book = new Book(); + PersistentResource resource = new PersistentResource<>(book, "123", scope); + + bookHook.execute(LifeCycleHookBinding.Operation.CREATE, LifeCycleHookBinding.TransactionPhase.PRECOMMIT, + new CRUDEvent( + LifeCycleHookBinding.Operation.CREATE, + resource, + "", + Optional.empty() + )); + + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + verify(context).createTopic(topicCaptor.capture()); + assertEquals("bookAdded", topicCaptor.getValue()); + verify(producer, times(1)).send(isA(Destination.class), isA(String.class)); + } +} diff --git a/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionScannerTest.java b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionScannerTest.java new file mode 100644 index 0000000000..8424ca146f --- /dev/null +++ b/elide-graphql/src/test/java/com/yahoo/elide/graphql/subscriptions/hooks/SubscriptionScannerTest.java @@ -0,0 +1,104 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.graphql.subscriptions.hooks; + +import static com.yahoo.elide.core.PersistentResource.CLASS_NO_FIELD; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import example.Author; +import example.Book; +import org.junit.jupiter.api.Test; + +import javax.jms.ConnectionFactory; + +public class SubscriptionScannerTest { + + @Test + public void testLifeCycleHookBindings() { + ConnectionFactory connectionFactory = mock(ConnectionFactory.class); + ClassScanner classScanner = DefaultClassScanner.getInstance(); + EntityDictionary dictionary = EntityDictionary.builder().scanner(classScanner).build(); + + SubscriptionScanner subscriptionScanner = SubscriptionScanner.builder() + .connectionFactory(connectionFactory) + .dictionary(dictionary) + .scanner(classScanner) + .build(); + + subscriptionScanner.bindLifecycleHooks(); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Book.class), + LifeCycleHookBinding.Operation.CREATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + CLASS_NO_FIELD).size()); + + assertEquals(0, dictionary.getTriggers(ClassType.of(Book.class), + LifeCycleHookBinding.Operation.DELETE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + CLASS_NO_FIELD).size()); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Book.class), + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + "title").size()); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Book.class), + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + "genre").size()); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Book.class), + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + "authors").size()); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Book.class), + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + "previews").size()); + + assertEquals(0, dictionary.getTriggers(ClassType.of(Book.class), + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + "price").size()); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Author.class), + LifeCycleHookBinding.Operation.CREATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + CLASS_NO_FIELD).size()); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Author.class), + LifeCycleHookBinding.Operation.DELETE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + CLASS_NO_FIELD).size()); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Author.class), + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + "name").size()); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Author.class), + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + "type").size()); + + assertEquals(1, dictionary.getTriggers(ClassType.of(Author.class), + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + "homeAddress").size()); + + assertEquals(0, dictionary.getTriggers(ClassType.of(Author.class), + LifeCycleHookBinding.Operation.UPDATE, + LifeCycleHookBinding.TransactionPhase.POSTCOMMIT, + "birthDate").size()); + } +} diff --git a/elide-graphql/src/test/java/example/Author.java b/elide-graphql/src/test/java/example/Author.java index 4f36cef946..3bd6b8de4c 100644 --- a/elide-graphql/src/test/java/example/Author.java +++ b/elide-graphql/src/test/java/example/Author.java @@ -8,8 +8,8 @@ import com.yahoo.elide.annotation.Audit; import com.yahoo.elide.annotation.ComputedAttribute; import com.yahoo.elide.annotation.Include; -import com.yahoo.elide.graphql.subscriptions.Subscription; -import com.yahoo.elide.graphql.subscriptions.SubscriptionField; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; import lombok.Builder; import java.util.ArrayList; diff --git a/elide-graphql/src/test/java/example/Book.java b/elide-graphql/src/test/java/example/Book.java index 19416649a0..1e68efe38d 100644 --- a/elide-graphql/src/test/java/example/Book.java +++ b/elide-graphql/src/test/java/example/Book.java @@ -7,8 +7,8 @@ import com.yahoo.elide.annotation.Audit; import com.yahoo.elide.annotation.Include; -import com.yahoo.elide.graphql.subscriptions.Subscription; -import com.yahoo.elide.graphql.subscriptions.SubscriptionField; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Builder; import lombok.Singular; diff --git a/elide-graphql/src/test/java/example/Job.java b/elide-graphql/src/test/java/example/Job.java new file mode 100644 index 0000000000..b31f607f44 --- /dev/null +++ b/elide-graphql/src/test/java/example/Job.java @@ -0,0 +1,49 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example; + +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.LifeCycleHookBinding; + +import hooks.JobLifeCycleHook; +import lombok.Data; + +import javax.persistence.Id; + +/** + * Tests lifecycle hooks in GraphQL that update model state. + */ +@Include +@Data +@LifeCycleHookBinding( + hook = JobLifeCycleHook.class, + operation = LifeCycleHookBinding.Operation.CREATE, + phase = LifeCycleHookBinding.TransactionPhase.PREFLUSH +) +@LifeCycleHookBinding( + hook = JobLifeCycleHook.class, + operation = LifeCycleHookBinding.Operation.CREATE, + phase = LifeCycleHookBinding.TransactionPhase.PRESECURITY +) +@LifeCycleHookBinding( + hook = JobLifeCycleHook.class, + operation = LifeCycleHookBinding.Operation.UPDATE, + phase = LifeCycleHookBinding.TransactionPhase.PREFLUSH +) +@LifeCycleHookBinding( + hook = JobLifeCycleHook.class, + operation = LifeCycleHookBinding.Operation.DELETE, + phase = LifeCycleHookBinding.TransactionPhase.PREFLUSH +) +public class Job { + @Id + private long id; + + private int status = 0; + + private String result; +} diff --git a/elide-graphql/src/test/java/example/Preview.java b/elide-graphql/src/test/java/example/Preview.java index b160f13f33..331166f8ad 100644 --- a/elide-graphql/src/test/java/example/Preview.java +++ b/elide-graphql/src/test/java/example/Preview.java @@ -6,6 +6,8 @@ package example; import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; import java.util.UUID; import javax.persistence.Entity; @@ -14,10 +16,12 @@ @Include // optional here because class has this name @Entity +@Subscription(operations = {}) //Denotes a custom subscription public class Preview { @Id private UUID id; @ManyToOne + @SubscriptionField private Book book; } diff --git a/elide-graphql/src/test/java/hooks/JobLifeCycleHook.java b/elide-graphql/src/test/java/hooks/JobLifeCycleHook.java new file mode 100644 index 0000000000..afd8240dfc --- /dev/null +++ b/elide-graphql/src/test/java/hooks/JobLifeCycleHook.java @@ -0,0 +1,57 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package hooks; + +import com.yahoo.elide.annotation.LifeCycleHookBinding; +import com.yahoo.elide.core.lifecycle.LifeCycleHook; +import com.yahoo.elide.core.security.ChangeSpec; +import com.yahoo.elide.core.security.RequestScope; +import example.Job; +import lombok.Setter; + +import java.util.Optional; +import javax.inject.Inject; + +/** + * Tests a hooks in GraphQL. + */ +public class JobLifeCycleHook implements LifeCycleHook { + + public interface JobService { + void jobDeleted(Job job); + } + + @Inject + @Setter + private JobService jobService; + + + @Override + public void execute( + LifeCycleHookBinding.Operation operation, + LifeCycleHookBinding.TransactionPhase phase, + Job job, RequestScope requestScope, + Optional changes + ) { + switch (operation) { + case DELETE: { + jobService.jobDeleted(job); + } + case UPDATE: { + job.setStatus(2); + return; + } + case CREATE: { + if (phase == LifeCycleHookBinding.TransactionPhase.PRESECURITY) { + job.setResult("Pending"); + } else { + job.setStatus(1); + } + } + }; + } +} diff --git a/elide-graphql/src/test/resources/graphql/requests/update/updateComplexAttributeMap.graphql b/elide-graphql/src/test/resources/graphql/requests/update/updateComplexAttributeMap.graphql index ef9790df5e..f425c3827d 100644 --- a/elide-graphql/src/test/resources/graphql/requests/update/updateComplexAttributeMap.graphql +++ b/elide-graphql/src/test/resources/graphql/requests/update/updateComplexAttributeMap.graphql @@ -3,8 +3,8 @@ mutation { data: { id: "1", priceRevisions: [ - { key: "2016-01-02T00:00Z", value: { units: 126, currency: { currencyCode: "USD" }}}, - { key: "2016-01-01T00:00Z", value: { units: 125}} + { key: "2016-01-02T00:00Z", value: { units: 490056468, currency: { currencyCode: "USD" }}}, + { key: "2016-01-01T00:00Z", value: { units: 125.7}} ] }) { edges { diff --git a/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttribute.json b/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttribute.json index 3491e4c01e..62213cc7fb 100644 --- a/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttribute.json +++ b/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttribute.json @@ -6,7 +6,7 @@ "id": "1", "title": "Libro Uno", "price": { - "units" : 123.0, + "units" : 123, "currency" : { "currencyCode" : "USD" } diff --git a/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttributeList.json b/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttributeList.json index 852377c020..8be28561ef 100644 --- a/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttributeList.json +++ b/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttributeList.json @@ -14,13 +14,13 @@ "title": "Libro Dos", "priceHistory": [ { - "units" : 200.0, + "units" : 200, "currency" : { "currencyCode" : "USD" } }, { - "units" : 210.0, + "units" : 210, "currency": { "currencyCode" : "USD" } diff --git a/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttributeMap.json b/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttributeMap.json index 9374b4d662..d42779bf5d 100644 --- a/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttributeMap.json +++ b/elide-graphql/src/test/resources/graphql/responses/fetch/complexAttributeMap.json @@ -16,7 +16,7 @@ { "key" : "2020-05-22T22:48Z", "value" : { - "units" : 210.0, + "units" : 210, "currency" : { "currencyCode" : "USD" } @@ -25,7 +25,7 @@ { "key" : "2020-05-22T22:46Z", "value" : { - "units" : 200.0, + "units" : 200, "currency" : { "currencyCode" : "USD" } diff --git a/elide-graphql/src/test/resources/graphql/responses/update/updateComplexAttributeList.json b/elide-graphql/src/test/resources/graphql/responses/update/updateComplexAttributeList.json index 75b0250b43..a6bf45dd7b 100644 --- a/elide-graphql/src/test/resources/graphql/responses/update/updateComplexAttributeList.json +++ b/elide-graphql/src/test/resources/graphql/responses/update/updateComplexAttributeList.json @@ -6,11 +6,11 @@ "id": "1", "priceHistory": [ { - "units": 125.0, + "units": 125, "currency": null }, { - "units": 126.0, + "units": 126, "currency": { "currencyCode" : "USD" } diff --git a/elide-graphql/src/test/resources/graphql/responses/update/updateComplexAttributeMap.json b/elide-graphql/src/test/resources/graphql/responses/update/updateComplexAttributeMap.json index 30d25d8955..6a05e7e472 100644 --- a/elide-graphql/src/test/resources/graphql/responses/update/updateComplexAttributeMap.json +++ b/elide-graphql/src/test/resources/graphql/responses/update/updateComplexAttributeMap.json @@ -8,7 +8,7 @@ { "key": "2016-01-02T00:00Z", "value": { - "units": 126.0, + "units": 490056468, "currency": { "currencyCode": "USD" } @@ -17,7 +17,7 @@ { "key": "2016-01-01T00:00Z", "value": { - "units": 125.0, + "units": 125.7, "currency": null } diff --git a/elide-integration-tests/pom.xml b/elide-integration-tests/pom.xml index 4dd77cbb14..e1d811c8ad 100644 --- a/elide-integration-tests/pom.xml +++ b/elide-integration-tests/pom.xml @@ -13,7 +13,7 @@ com.yahoo.elide elide-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -25,7 +25,7 @@ - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -84,18 +84,6 @@ javax.persistence-api test - - org.hibernate - hibernate-core - ${hibernate3.version} - provided - - - * - * - - - org.hibernate hibernate-validator @@ -103,15 +91,15 @@ test - org.javassist - javassist - 3.28.0-GA - test + org.hibernate + hibernate-core + ${hibernate5.version} + provided - com.fasterxml.jackson.datatype - jackson-datatype-hibernate3 - ${version.jackson} + org.javassist + javassist + 3.29.2-GA test @@ -155,7 +143,7 @@ org.skyscreamer jsonassert - 1.5.0 + 1.5.1 diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/AsyncDelayStoreTransaction.java b/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/AsyncDelayStoreTransaction.java index 46fac2cee1..6503262c15 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/AsyncDelayStoreTransaction.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/AsyncDelayStoreTransaction.java @@ -7,6 +7,7 @@ package com.yahoo.elide.async.integration.tests; import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; import com.yahoo.elide.core.datastore.DataStoreTransaction; import com.yahoo.elide.core.datastore.wrapped.TransactionWrapper; import com.yahoo.elide.core.request.EntityProjection; @@ -27,7 +28,7 @@ public AsyncDelayStoreTransaction(DataStoreTransaction tx) { } @Override - public Iterable loadObjects(EntityProjection entityProjection, RequestScope scope) { + public DataStoreIterable loadObjects(EntityProjection entityProjection, RequestScope scope) { try { log.debug("LoadObjects Sleep for delay test"); diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/AsyncIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/AsyncIT.java index a59aea02a3..fdb309db7b 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/AsyncIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/AsyncIT.java @@ -497,6 +497,8 @@ public void asyncQueryModelAdminReadPermissions() throws IOException { .build()) .withAuditLogger(new TestAuditLogger()).build()); + elide.doScans(); + User ownerUser = new User(() -> "owner-user"); SecurityContextUser securityContextAdminUser = new SecurityContextUser(new SecurityContext() { @Override diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/TableExportIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/TableExportIT.java index cfa944c59f..61a2dbe357 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/TableExportIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/TableExportIT.java @@ -402,7 +402,7 @@ public void graphQLHappyPath1Alias() throws InterruptedException, IOException { + "\"httpStatus\":200,\"recordCount\":3}}}]}}}"; assertEquals(expectedResponse, responseGraphQL); - assertEquals("\"bookName\"\n" + assertEquals("\"title\"\n" + "\"Ender's Game\"\n" + "\"Song of Ice and Fire\"\n" + "\"For Whom the Bell Tolls\"\n", getStoredFileContents(getPort(), "edc4a871-dff2-4054-804e-d80075cab28e")); @@ -570,7 +570,7 @@ public void graphQLTestCreateFailOnUnSupportedResultType() { .then() .statusCode(org.apache.http.HttpStatus.SC_OK) .body(containsString("errors")) - .body(containsString("Validation error of type WrongType: argument 'data.resultType'")); + .body(containsString("Validation error (WrongType@[tableExport]) : argument 'data.resultType'")); } /** diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/framework/AsyncIntegrationTestApplicationResourceConfig.java b/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/framework/AsyncIntegrationTestApplicationResourceConfig.java index 58a606c778..c3e25663b3 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/framework/AsyncIntegrationTestApplicationResourceConfig.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/async/integration/tests/framework/AsyncIntegrationTestApplicationResourceConfig.java @@ -6,10 +6,10 @@ package com.yahoo.elide.async.integration.tests.framework; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; -import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.UPDATE; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.POSTCOMMIT; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRECOMMIT; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PREFLUSH; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRESECURITY; import com.yahoo.elide.Elide; import com.yahoo.elide.ElideSettingsBuilder; @@ -87,7 +87,7 @@ protected void configure() { bind(dictionary).to(EntityDictionary.class); DefaultFilterDialect defaultFilterStrategy = new DefaultFilterDialect(dictionary); - RSQLFilterDialect rsqlFilterStrategy = new RSQLFilterDialect(dictionary); + RSQLFilterDialect rsqlFilterStrategy = RSQLFilterDialect.builder().dictionary(dictionary).build(); MultipleFilterDialect multipleFilterStrategy = new MultipleFilterDialect( Arrays.asList(rsqlFilterStrategy, defaultFilterStrategy), @@ -104,6 +104,8 @@ protected void configure() { .build()); bind(elide).to(Elide.class).named("elide"); + elide.doScans(); + AsyncAPIDAO asyncAPIDao = new DefaultAsyncAPIDAO(elide.getElideSettings(), elide.getDataStore()); bind(asyncAPIDao).to(AsyncAPIDAO.class); @@ -114,7 +116,7 @@ protected void configure() { // Create ResultStorageEngine Path storageDestination = (Path) servletContext.getAttribute(STORAGE_DESTINATION_ATTR); if (storageDestination != null) { // TableExport is enabled - ResultStorageEngine resultStorageEngine = new FileResultStorageEngine(storageDestination.toAbsolutePath().toString()); + ResultStorageEngine resultStorageEngine = new FileResultStorageEngine(storageDestination.toAbsolutePath().toString(), false); bind(resultStorageEngine).to(ResultStorageEngine.class).named("resultStorageEngine"); Map supportedFormatters = new HashMap<>(); @@ -124,7 +126,7 @@ protected void configure() { // Binding TableExport LifeCycleHook TableExportHook tableExportHook = new TableExportHook(asyncExecutorService, 10, supportedFormatters, resultStorageEngine); - dictionary.bindTrigger(TableExport.class, READ, PRESECURITY, tableExportHook, false); + dictionary.bindTrigger(TableExport.class, CREATE, PREFLUSH, tableExportHook, false); dictionary.bindTrigger(TableExport.class, CREATE, POSTCOMMIT, tableExportHook, false); dictionary.bindTrigger(TableExport.class, CREATE, PRESECURITY, tableExportHook, false); @@ -146,7 +148,7 @@ public long purchase(Invoice invoice) { InvoiceCompletionHook invoiceCompletionHook = new InvoiceCompletionHook(billingService); - dictionary.bindTrigger(AsyncQuery.class, READ, PRESECURITY, asyncQueryHook, false); + dictionary.bindTrigger(AsyncQuery.class, CREATE, PREFLUSH, asyncQueryHook, false); dictionary.bindTrigger(AsyncQuery.class, CREATE, POSTCOMMIT, asyncQueryHook, false); dictionary.bindTrigger(AsyncQuery.class, CREATE, PRESECURITY, asyncQueryHook, false); dictionary.bindTrigger(Invoice.class, "complete", CREATE, PRECOMMIT, invoiceCompletionHook); diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/errorEncodingTests/EncodedErrorObjectsIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/errorEncodingTests/EncodedErrorObjectsIT.java index b1bdc4e26d..711d155ab5 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/errorEncodingTests/EncodedErrorObjectsIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/errorEncodingTests/EncodedErrorObjectsIT.java @@ -115,7 +115,7 @@ public void transactionException() { .accept(JSONAPI_CONTENT_TYPE) .body(request).post("/invoice") .then() - .statusCode(HttpStatus.SC_LOCKED) + .statusCode(HttpStatus.SC_BAD_REQUEST) .body(equalTo(expected)); } diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/errorEncodingTests/VerboseEncodedErrorResponsesIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/errorEncodingTests/VerboseEncodedErrorResponsesIT.java index 18a5b34f43..5917f0844a 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/errorEncodingTests/VerboseEncodedErrorResponsesIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/errorEncodingTests/VerboseEncodedErrorResponsesIT.java @@ -126,7 +126,7 @@ public void transactionException() { .accept(JSONAPI_CONTENT_TYPE) .body(request).post("/invoice") .then() - .statusCode(HttpStatus.SC_LOCKED) + .statusCode(HttpStatus.SC_BAD_REQUEST) .body(equalTo(expected)); } diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java index 8e9ad108b4..1d42a807f8 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/InMemoryDataStoreHarness.java @@ -19,6 +19,7 @@ import example.Company; import example.Parent; import example.models.generics.Manager; +import example.models.targetEntity.SWE; import example.models.triggers.Invoice; import example.models.versioned.BookV2; @@ -34,6 +35,7 @@ public class InMemoryDataStoreHarness implements DataStoreTestHarness { public InMemoryDataStoreHarness() { Set beanPackages = Sets.newHashSet( Parent.class.getPackage(), + SWE.class.getPackage(), Invoice.class.getPackage(), Manager.class.getPackage(), BookV2.class.getPackage(), diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/LifeCycleIntegrationTestApplicationResourceConfig.java b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/LifeCycleIntegrationTestApplicationResourceConfig.java index b9da6b4b41..6459e6373c 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/LifeCycleIntegrationTestApplicationResourceConfig.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/LifeCycleIntegrationTestApplicationResourceConfig.java @@ -42,7 +42,7 @@ protected void configure() { bind(dictionary).to(EntityDictionary.class); DefaultFilterDialect defaultFilterStrategy = new DefaultFilterDialect(dictionary); - RSQLFilterDialect rsqlFilterStrategy = new RSQLFilterDialect(dictionary); + RSQLFilterDialect rsqlFilterStrategy = RSQLFilterDialect.builder().dictionary(dictionary).build(); MultipleFilterDialect multipleFilterStrategy = new MultipleFilterDialect( Arrays.asList(rsqlFilterStrategy, defaultFilterStrategy), @@ -56,6 +56,7 @@ protected void configure() { .withEntityDictionary(dictionary) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", Calendar.getInstance().getTimeZone()) .build()); + elide.doScans(); bind(elide).to(Elide.class).named("elide"); BillingService billingService = new BillingService() { diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/StandardTestBinder.java b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/StandardTestBinder.java index 86b167c97c..933cfa6dc5 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/StandardTestBinder.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/StandardTestBinder.java @@ -50,20 +50,23 @@ protected void configure() { @Override public Elide provide() { DefaultFilterDialect defaultFilterStrategy = new DefaultFilterDialect(dictionary); - RSQLFilterDialect rsqlFilterStrategy = new RSQLFilterDialect(dictionary); + RSQLFilterDialect rsqlFilterStrategy = RSQLFilterDialect.builder().dictionary(dictionary).build(); MultipleFilterDialect multipleFilterStrategy = new MultipleFilterDialect( Arrays.asList(rsqlFilterStrategy, defaultFilterStrategy), Arrays.asList(rsqlFilterStrategy, defaultFilterStrategy) ); - return new Elide(new ElideSettingsBuilder(IntegrationTest.getDataStore()) + Elide elide = new Elide(new ElideSettingsBuilder(IntegrationTest.getDataStore()) .withAuditLogger(auditLogger) .withJoinFilterDialect(multipleFilterStrategy) .withSubqueryFilterDialect(multipleFilterStrategy) .withEntityDictionary(dictionary) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", Calendar.getInstance().getTimeZone()) .build()); + + elide.doScans(); + return elide; } @Override diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/VerboseErrorResponsesTestBinder.java b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/VerboseErrorResponsesTestBinder.java index 500b2bf9e2..74b5c7495b 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/VerboseErrorResponsesTestBinder.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/initialization/VerboseErrorResponsesTestBinder.java @@ -50,20 +50,23 @@ protected void configure() { @Override public Elide provide() { DefaultFilterDialect defaultFilterStrategy = new DefaultFilterDialect(dictionary); - RSQLFilterDialect rsqlFilterStrategy = new RSQLFilterDialect(dictionary); + RSQLFilterDialect rsqlFilterStrategy = RSQLFilterDialect.builder().dictionary(dictionary).build(); MultipleFilterDialect multipleFilterStrategy = new MultipleFilterDialect( Arrays.asList(rsqlFilterStrategy, defaultFilterStrategy), Arrays.asList(rsqlFilterStrategy, defaultFilterStrategy) ); - return new Elide(new ElideSettingsBuilder(getDataStore()) + Elide elide = new Elide(new ElideSettingsBuilder(getDataStore()) .withAuditLogger(auditLogger) .withJoinFilterDialect(multipleFilterStrategy) .withSubqueryFilterDialect(multipleFilterStrategy) .withEntityDictionary(dictionary) .withVerboseErrors() .build()); + + elide.doScans(); + return elide; } @Override diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/targetEntity/TargetEntityIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/targetEntity/TargetEntityIT.java new file mode 100644 index 0000000000..d51fbe5135 --- /dev/null +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/targetEntity/TargetEntityIT.java @@ -0,0 +1,97 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.targetEntity; + +import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.linkage; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relation; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.relationships; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; + +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.initialization.IntegrationTest; +import org.junit.jupiter.api.Test; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TargetEntityIT extends IntegrationTest { + + @Test + public void testEmployeeHierarchy() { + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("swe"), + attributes( + attr("name", "peon") + ) + ) + ) + ) + .post("/swe") + .then() + .log().all() + .statusCode(HttpStatus.SC_CREATED) + .body("data.id", equalTo("1")); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("boss"), + attributes( + attr("name", "boss") + ), + relationships( + relation("reports", linkage(type("swe"), id("1"))) + ) + ) + ) + ) + .post("/boss") + .then() + .log().all() + .statusCode(HttpStatus.SC_CREATED) + .body("data.id", equalTo("1")); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .when() + .get("/boss/1") + .then() + .log().all() + .statusCode(org.apache.http.HttpStatus.SC_OK) + .body("data.id", equalTo("1"), + "data.relationships.reports.data.id", contains("1"), + "data.relationships.reports.data.type", contains("swe") + ); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .when() + .get("/swe/1") + .then() + .statusCode(org.apache.http.HttpStatus.SC_OK) + .body("data.id", equalTo("1"), + "data.relationships.boss.data.id", equalTo("1"), + "data.relationships.boss.data.type", equalTo("boss") + ); + } +} diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/DataStoreIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/DataStoreIT.java index 3d14d14cc8..5bf3969a5a 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/DataStoreIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/DataStoreIT.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -75,6 +75,8 @@ public DataStoreIT() { .withAuditLogger(new TestAuditLogger()) .withEntityDictionary(EntityDictionary.builder().checks(checks).build()) .build()); + + elide.doScans(); } @BeforeEach diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/FilterIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/FilterIT.java index ed41c88f91..797b9f7377 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/FilterIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/FilterIT.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -1478,7 +1478,7 @@ void testFilterEditorByFullName() { } @Test - void testFilterByAuthorBookByChapter() throws JsonProcessingException { + void testFilterByAuthorBookByChapter() { /* Test default */ given() .get(String.format("/author/%s/books?filter[book.chapters.title]=Viva la Roma!", asimovId)) diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/ResourceIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/ResourceIT.java index abc7ed11a3..636486aaf3 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/ResourceIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/ResourceIT.java @@ -23,6 +23,7 @@ import static com.yahoo.elide.test.jsonapi.elements.Relation.TO_ONE; import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.isEmptyOrNullString; import static org.hamcrest.Matchers.nullValue; @@ -78,6 +79,7 @@ import java.io.IOException; import java.util.HashSet; +import java.util.Map; import java.util.Set; import java.util.stream.Stream; @@ -92,7 +94,7 @@ public class ResourceIT extends IntegrationTest { type("company"), id("abc"), attributes( - attr("address", buildAddress("street1", null)), + attr("address", buildAddress("street1", null, Map.of("foo", "bar"))), attr("description", "ABC") ) ); @@ -220,10 +222,11 @@ public class ResourceIT extends IntegrationTest { private final JsonParser jsonParser = new JsonParser(); private final String baseUrl = "http://localhost:8080/api"; - private static Address buildAddress(String street1, String street2) { + private static Address buildAddress(String street1, String street2, Map properties) { final Address address = new Address(); address.setStreet1(street1); address.setStreet2(street2); + address.setProperties(properties); return address; } @@ -254,6 +257,12 @@ public void setup() throws IOException { company.setId("abc"); company.setDescription("ABC"); + Address address = new Address(); + address.setStreet1("foo"); + address.setProperties(Map.of("boo", "baz")); + + company.setAddress(address); + tx.createObject(company, null); Parent parent = new Parent(); // id 1 @@ -649,6 +658,8 @@ public void testPatchComplexAttribute() { final Address update = new Address(); update.setStreet1("street1"); + update.setProperties(Map.of("foo", "bar")); + // update company address given() .contentType(JSONAPI_CONTENT_TYPE) @@ -948,6 +959,34 @@ public void testGetIncludeBadRelation() { .statusCode(HttpStatus.SC_BAD_REQUEST); } + @Test + public void testMissingTypeInJsonBody() { + String detail = "Resource 'type' field is missing or empty."; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body("{ \"data\": { \"id\": \"1\", \"attributes\": { \"firstName\": \"foo\" }}}") + .patch("/parent/1") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body("errors[0].detail", equalTo(Encode.forHtml(detail))); + } + + @Test + public void testInvalidJson() { + String detail = "Unexpected close marker ']': expected '}' (for Object starting at [Source: (String)\"{ ]\"; line: 1, column: 1])\n at [Source: (String)\"{ ]\"; line: 1, column: 4]"; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body("{ ]") + .patch("/parent/1") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body("errors[0].detail", equalTo(Encode.forHtml(detail))); + } + @Test public void testGetSortCollection() throws Exception { @@ -1404,6 +1443,22 @@ public void createChildRelateExisting() { .body(equalTo(expected)); } + @Test + public void invalidPatchNullOp() { + String request = jsonParser.getJson("/ResourceIT/patchExtNullOp.req.json"); + + String detail = "Patch extension operation cannot be null."; + + given() + .contentType(JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION) + .accept(JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION) + .body(request) + .patch("/") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body("errors[0].detail[0]", containsString(Encode.forHtml(detail))); + } + @Test public void invalidPatchMissingId() { String request = jsonParser.getJson("/ResourceIT/invalidPatchMissingId.req.json"); @@ -1421,6 +1476,22 @@ public void invalidPatchMissingId() { .body("errors[0].detail[0]", equalTo(Encode.forHtml(detail))); } + @Test + public void invalidPatchMissingPath() { + String request = jsonParser.getJson("/ResourceIT/invalidPatchMissingPath.req.json"); + + String detail = "Bad Request Body'Patch extension requires all objects to have an assigned path'"; + + given() + .contentType(JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION) + .accept(JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION) + .body(request) + .patch("/") + .then() + .statusCode(HttpStatus.SC_BAD_REQUEST) + .body("errors[0].detail[0]", equalTo(Encode.forHtml(detail))); + } + @Test public void updateChildRelationToExisting() { String request = jsonParser.getJson("/ResourceIT/updateChildRelationToExisting.req.json"); @@ -1671,7 +1742,7 @@ public void removeRelationshipChild() { } @Test - public void addRelationships() throws IOException { + public void addRelationships() { String request = jsonParser.getJson("/ResourceIT/addRelationships.req.json"); String expected1 = jsonParser.getJson("/ResourceIT/addRelationships.json"); String expected2 = jsonParser.getJson("/ResourceIT/addRelationships.2.json"); @@ -2175,6 +2246,20 @@ public void testFilterIds() { ).toJSON())); } + @Test + public void testIssue608() { + String req = jsonParser.getJson("/ResourceIT/Issue608.req.json"); + String expected = jsonParser.getJson("/ResourceIT/Issue608.resp.json"); + given() + .contentType(JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION) + .accept(JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION) + .body(req) + .patch("/parent") + .then() + .statusCode(HttpStatus.SC_OK) + .body(equalTo(expected)); + } + @Test public void testNestedPatch() { String req = jsonParser.getJson("/ResourceIT/nestedPatchCreate.req.json"); @@ -2491,10 +2576,12 @@ public void elideSecurityEnabled() { .withAuditLogger(new TestAuditLogger()) .build()); + elide.doScans(); + com.yahoo.elide.core.security.User user = new com.yahoo.elide.core.security.User(() -> "-1"); ElideResponse response = elide.get(baseUrl, "parent/1/children", new MultivaluedHashMap<>(), user, NO_VERSION); - assertEquals(response.getResponseCode(), HttpStatus.SC_OK); - assertEquals(response.getBody(), "{\"data\":[]}"); + assertEquals(HttpStatus.SC_OK, response.getResponseCode()); + assertEquals("{\"data\":[]}", response.getBody()); } @Test @@ -2559,6 +2646,9 @@ public void testUpdateDeferredOnCreate() { attributes( attr("cannotModify", "unmodified"), attr("textValue", "new value") + ), + relationships( + relation("toOneRelation", true) ) ); @@ -2608,6 +2698,58 @@ public void testUpdateDeferredOnCreate() { .statusCode(HttpStatus.SC_FORBIDDEN); } + @Test + public void testUpdatingExistingResourceWithoutPermissionsIsForbidden() { + + Resource entity1 = resource( + type("createButNoUpdate"), + id("1"), + attributes( + attr("textValue", "new value") + ) + ); + + Resource entity2 = resource( + type("createButNoUpdate"), + id("2"), + attributes( + attr("textValue", "new value") + ), + relationships( + relation("toOneRelation", linkage(type("createButNoUpdate"), id("1"))) + ) + ); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body(datum(entity1)) + .post("/createButNoUpdate") + .then() + .statusCode(HttpStatus.SC_CREATED); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body(datum(entity2)) + .post("/createButNoUpdate") + .then() + .statusCode(HttpStatus.SC_CREATED); + + + Data relationships = data( + linkage(type("createButNoUpdate"), id("1")) + ); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body(relationships) + .post("/createButNoUpdate/2/relationships/toOneRelation") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN); + } + @Test public void testPatchDeferredOnCreate() { String request = jsonParser.getJson("/ResourceIT/testPatchDeferredOnCreate.req.json"); @@ -2831,7 +2973,6 @@ public void testVersionedPatchExtension() { .body(req) .patch("/book") .then() - .log().all() .statusCode(HttpStatus.SC_OK) .body(equalTo(expected)); } diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/SortingIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/SortingIT.java index 7044c9c60f..be350b8c85 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/SortingIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/SortingIT.java @@ -8,6 +8,7 @@ import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -209,7 +210,7 @@ public void testSortingById() throws IOException { } @Test - public void testRootCollectionByNullRelationshipProperty() throws IOException { + public void testRootCollectionByNullRelationshipProperty() { // Test whether all book records are received when() .get("/book?sort=publisher.editor.lastName") @@ -225,7 +226,7 @@ public void testRootCollectionByNullRelationshipProperty() throws IOException { } @Test - public void testSubcollectionByNullRelationshipProperty() throws IOException { + public void testSubcollectionByNullRelationshipProperty() { when() .get("/author/1/books?sort=publisher.editor.lastName") .then() @@ -237,4 +238,28 @@ public void testSubcollectionByNullRelationshipProperty() throws IOException { .body("data", hasSize(2)) ; } + + /** + * Tests a computed relationship sort. + */ + @Test + void testSortByComputedRelationship() { + given() + .get("/book?sort=-editor.firstName") + .then() + .statusCode(org.apache.http.HttpStatus.SC_OK) + .body("data[0].relationships.editor.data.id", equalTo("1")); + } + + /** + * Tests a computed attribute sort. + */ + @Test + void testSortByComputedAttribute() { + given() + .get("/editor?sort=-fullName") + .then() + .statusCode(org.apache.http.HttpStatus.SC_OK) + .body("data[0].attributes.fullName", equalTo("John Doe")); + } } diff --git a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/UserTypeIT.java b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/UserTypeIT.java index 06e4885fe7..b2c7262594 100644 --- a/elide-integration-tests/src/test/java/com/yahoo/elide/tests/UserTypeIT.java +++ b/elide-integration-tests/src/test/java/com/yahoo/elide/tests/UserTypeIT.java @@ -58,6 +58,11 @@ public void testUserTypePost() throws Exception { "123 AnyStreet Dr", "IL", new Zip("61820", "1234") + )), + attr("alternateAddress", new Address( + "XYZ AnyStreet Dr", + "IL", + new Zip("61820", "1234") )) ) ); @@ -91,6 +96,11 @@ public void testUserTypePatch() throws Exception { "456 AnyStreet Dr", "IL", new Zip("61822", "567") + )), + attr("alternateAddress", new Address( + "XYZ AnyStreet Dr", + "IL", + new Zip("61820", "1234") )) ) ); @@ -104,6 +114,11 @@ public void testUserTypePatch() throws Exception { "456 AnyRoad Ave", "AZ", new Zip("85001", "9999") + )), + attr("alternateAddress", new Address( + "ABC AnyStreet Dr", + "CO", + new Zip("12345", "1234") )) ) ); @@ -149,7 +164,8 @@ public void testUserTypeMissingUserTypeField() throws Exception { id("3"), attributes( attr("name", "DM"), - attr("address", null) + attr("address", null), + attr("alternateAddress", null) ) ); @@ -187,7 +203,8 @@ public void testUserTypeMissingUserTypeProperties() throws Exception { id("4"), attributes( attr("name", "WC"), - attr("address", partialAddress) + attr("address", partialAddress), + attr("alternateAddress", partialAddress) ) ); @@ -200,6 +217,11 @@ public void testUserTypeMissingUserTypeProperties() throws Exception { "1400 AnyAve St", null, new Zip("60412", null) + )), + attr("alternateAddress", new Address( + "1400 AnyAve St", + null, + new Zip("60412", null) )) ) ); diff --git a/elide-integration-tests/src/test/java/example/Address.java b/elide-integration-tests/src/test/java/example/Address.java index 056c052f7c..a7afd6752d 100644 --- a/elide-integration-tests/src/test/java/example/Address.java +++ b/elide-integration-tests/src/test/java/example/Address.java @@ -5,8 +5,17 @@ */ package example; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Data; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import javax.persistence.AttributeConverter; +import javax.persistence.Column; +import javax.persistence.Convert; import javax.persistence.Embeddable; @Embeddable @@ -16,4 +25,32 @@ public class Address { private String street1; private String street2; + @Column(name = "properties", columnDefinition = "TEXT") + @Convert(attributeName = "key", converter = MapConverter.class) + Map properties; + + public static class MapConverter implements AttributeConverter, String> { + ObjectMapper mapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(Map attribute) { + try { + return mapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + @Override + public Map convertToEntityAttribute(String dbData) { + try { + TypeReference> typeRef = new TypeReference<>() { + }; + + return mapper.readValue(dbData, typeRef); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + } } diff --git a/elide-integration-tests/src/test/java/example/Author.java b/elide-integration-tests/src/test/java/example/Author.java index d82de7ffc3..cb17c1be43 100644 --- a/elide-integration-tests/src/test/java/example/Author.java +++ b/elide-integration-tests/src/test/java/example/Author.java @@ -39,12 +39,14 @@ logStatement = "{0}", logExpressions = {"${author.name}"}) public class Author { + @Setter + private Long id; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Getter - @Setter - private Long id; + public Long getId() { + return id; + } @Exclude private String naturalKey = UUID.randomUUID().toString(); @@ -52,14 +54,22 @@ public class Author { @Getter @Setter private String name; - @ManyToMany(mappedBy = "authors") - @Getter @Setter + @Setter private Collection books = new ArrayList<>(); - @Getter @Setter - @ReadPermission(expression = "Prefab.Role.None") + @ManyToMany(mappedBy = "authors") + public Collection getBooks() { + return books; + } + + @Setter private String homeAddress; + @ReadPermission(expression = "Prefab.Role.None") + public String getHomeAddress() { + return homeAddress; + } + @Override public int hashCode() { return naturalKey.hashCode(); diff --git a/elide-integration-tests/src/test/java/example/CreateButNoUpdate.java b/elide-integration-tests/src/test/java/example/CreateButNoUpdate.java index c378da1216..8e872517be 100644 --- a/elide-integration-tests/src/test/java/example/CreateButNoUpdate.java +++ b/elide-integration-tests/src/test/java/example/CreateButNoUpdate.java @@ -11,6 +11,7 @@ import com.yahoo.elide.annotation.UpdatePermission; import javax.persistence.Entity; +import javax.persistence.OneToOne; /** * A model intended to be ONLY created and read, but never updated. @@ -25,6 +26,8 @@ public class CreateButNoUpdate extends BaseId { private String cannotModify = "unmodified"; + private CreateButNoUpdate toOneRelation; + @CreatePermission(expression = "Prefab.Role.None") public String getCannotModify() { return cannotModify; @@ -41,4 +44,13 @@ public void setTextValue(String textValue) { public String getTextValue() { return textValue; } + + @OneToOne + public CreateButNoUpdate getToOneRelation() { + return toOneRelation; + } + + public void setToOneRelation(CreateButNoUpdate toOneRelation) { + this.toOneRelation = toOneRelation; + } } diff --git a/elide-integration-tests/src/test/java/example/models/targetEntity/Employee.java b/elide-integration-tests/src/test/java/example/models/targetEntity/Employee.java new file mode 100644 index 0000000000..d73823e5c8 --- /dev/null +++ b/elide-integration-tests/src/test/java/example/models/targetEntity/Employee.java @@ -0,0 +1,11 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.models.targetEntity; + +public interface Employee { + String getName(); +} diff --git a/elide-integration-tests/src/test/java/example/models/targetEntity/Manager.java b/elide-integration-tests/src/test/java/example/models/targetEntity/Manager.java new file mode 100644 index 0000000000..2ba0393c02 --- /dev/null +++ b/elide-integration-tests/src/test/java/example/models/targetEntity/Manager.java @@ -0,0 +1,34 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.models.targetEntity; + +import com.yahoo.elide.annotation.Include; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Set; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.OneToMany; + +@Include(name = "boss") +@Data +@Entity(name = "boss") +public class Manager implements Employee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + private String name; + + @OneToMany(targetEntity = SWE.class, mappedBy = "boss") + @EqualsAndHashCode.Exclude + Set reports; +} diff --git a/elide-integration-tests/src/test/java/example/models/targetEntity/SWE.java b/elide-integration-tests/src/test/java/example/models/targetEntity/SWE.java new file mode 100644 index 0000000000..8148be10bc --- /dev/null +++ b/elide-integration-tests/src/test/java/example/models/targetEntity/SWE.java @@ -0,0 +1,32 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.models.targetEntity; + +import com.yahoo.elide.annotation.Include; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.OneToOne; + +@Include(name = "swe") +@Data +@Entity +public class SWE { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + private String name; + + @OneToOne(targetEntity = Manager.class) + @EqualsAndHashCode.Exclude + private Employee boss; +} diff --git a/elide-integration-tests/src/test/resources/ResourceIT/Issue608.req.json b/elide-integration-tests/src/test/resources/ResourceIT/Issue608.req.json new file mode 100644 index 0000000000..478dcd1f9b --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/Issue608.req.json @@ -0,0 +1,37 @@ +[ + { + "op": "add", + "path": "/-", + "value": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "Parent1" + } + } + }, + { + "op": "replace", + "path": "/12345678-1234-1234-1234-123456789ab1", + "value": { + "type": "parent", + "id": "12345678-1234-1234-1234-123456789ab1", + "attributes": { + "firstName": "Corrected" + }, + "relationships": { + "children": { + "data": [{"type": "child", "id": "12345678-1234-1234-1234-123456789ab2"}] + } + } + } + }, + { + "op": "add", + "path": "/12345678-1234-1234-1234-123456789ab1/children", + "value": { + "type": "child", + "id": "12345678-1234-1234-1234-123456789ab2" + } + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/Issue608.resp.json b/elide-integration-tests/src/test/resources/ResourceIT/Issue608.resp.json new file mode 100644 index 0000000000..4973809057 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/Issue608.resp.json @@ -0,0 +1,49 @@ +[ + { + "data":{ + "type":"parent", + "id":"5", + "attributes":{ + "firstName":"Corrected" + }, + "relationships":{ + "children":{ + "data":[ + { + "type":"child", + "id":"6" + } + ] + }, + "spouses":{ + "data":[] + } + } + } + }, + { + "data":null + }, + { + "data":{ + "type":"child", + "id":"6", + "attributes":{ + "name":null + }, + "relationships":{ + "friends":{ + "data":[] + }, + "parents":{ + "data":[ + { + "type":"parent", + "id":"5" + } + ] + } + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/invalidPatchMissingPath.req.json b/elide-integration-tests/src/test/resources/ResourceIT/invalidPatchMissingPath.req.json new file mode 100644 index 0000000000..0827639d23 --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/invalidPatchMissingPath.req.json @@ -0,0 +1,27 @@ +[ + { + "op":"add", + "value":{ + "type":"book", + "id":"bookId", + "relationships":{ + "authors":{ + "data":[ + { + "type":"author", + "id":"authorId" + } + ] + } + } + } + }, + { + "op":"add", + "path":"/author", + "value":{ + "id":"authorId", + "type":"author" + } + } +] diff --git a/elide-integration-tests/src/test/resources/ResourceIT/patchExtNullOp.req.json b/elide-integration-tests/src/test/resources/ResourceIT/patchExtNullOp.req.json new file mode 100644 index 0000000000..556863bd8a --- /dev/null +++ b/elide-integration-tests/src/test/resources/ResourceIT/patchExtNullOp.req.json @@ -0,0 +1,13 @@ +[ + { + "op": null, + "path": "/book", + "value": { + "type": "book", + "id": "123", + "attributes": { + "title": "My New Book" + } + } + } +] diff --git a/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher1.json b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher1.json index c65fa8e88b..560a693ebf 100644 --- a/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher1.json +++ b/elide-integration-tests/src/test/resources/SortingIT/addAuthorBookPublisher1.json @@ -101,5 +101,17 @@ "name": "Super Publisher" } } + }, + { + "op": "add", + "path": "/book/12345678-1234-1234-1234-1234567890ad/publisher/12345678-1234-1234-1234-1234567890af/editor", + "value": { + "type": "editor", + "id": "12345678-1234-1234-1234-1234567890ba", + "attributes": { + "firstName": "John", + "lastName": "Doe" + } + } } ] diff --git a/elide-model-config/pom.xml b/elide-model-config/pom.xml index eab278ee4a..e28d3c9783 100644 --- a/elide-model-config/pom.xml +++ b/elide-model-config/pom.xml @@ -14,7 +14,7 @@ elide-parent-pom com.yahoo.elide - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -40,19 +40,19 @@ 3.0.0 - 4.2.0 + 4.3.1 2.2.14 2.11.0 - 1.4 + 1.5.0 1.21 - 5.3.9 + 5.3.23 com.yahoo.elide elide-core - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.fasterxml.jackson.core @@ -99,6 +99,14 @@ org.projectlombok lombok + + + + javax.persistence + javax.persistence-api + provided + + com.github.java-json-tools json-schema-validator @@ -117,7 +125,7 @@ com.github.stefanbirkner system-lambda - 1.2.0 + 1.2.1 test @@ -136,6 +144,11 @@ ${version.junit} test + + org.mockito + mockito-core + test + com.github.jknack handlebars-helpers @@ -174,14 +187,6 @@ org.apache.maven.plugins maven-checkstyle-plugin - - org.apache.maven.plugins - maven-compiler-plugin - - 1.8 - 1.8 - - diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/io/FileLoader.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/io/FileLoader.java new file mode 100644 index 0000000000..44e0600bde --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/io/FileLoader.java @@ -0,0 +1,174 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.io; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static java.nio.charset.StandardCharsets.UTF_8; +import com.yahoo.elide.modelconfig.DynamicConfigHelpers; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; + +import org.apache.commons.io.IOUtils; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** + * Responsible for loading HJSON configuration either from the classpath or from the file system. + */ +@Slf4j +public class FileLoader { + private static final Pattern TABLE_FILE = Pattern.compile("models/tables/[^/]+\\.hjson"); + private static final Pattern NAME_SPACE_FILE = Pattern.compile("models/namespaces/[^/]+\\.hjson"); + private static final Pattern DB_FILE = Pattern.compile("db/sql/[^/]+\\.hjson"); + private static final String CLASSPATH_PATTERN = "classpath*:"; + private static final String FILEPATH_PATTERN = "file:"; + private static final String RESOURCES = "resources"; + private static final int RESOURCES_LENGTH = 9; //"resources".length() + + private static final String HJSON_EXTN = "**/*.hjson"; + + private final PathMatchingResourcePatternResolver resolver; + + private static final Function CONTENT_PROVIDER = (resource) -> { + try { + return IOUtils.toString(resource.getInputStream(), UTF_8); + } catch (IOException e) { + log.error ("Error converting stream to String: {}", e.getMessage()); + throw new IllegalStateException(e); + } + }; + + @Getter + private final String rootPath; + private final String rootURL; + + @Getter + private boolean writeable; + + /** + * Constructor. + * @param rootPath The file system (or classpath) root directory path for configuration. + */ + public FileLoader(String rootPath) { + this.resolver = new PathMatchingResourcePatternResolver(this.getClass().getClassLoader()); + + String pattern = CLASSPATH_PATTERN + DynamicConfigHelpers.formatFilePath(formatClassPath(rootPath)); + + boolean classPathExists = false; + try { + classPathExists = (resolver.getResources(pattern).length != 0); + } catch (IOException e) { + //NOOP + } + + if (classPathExists) { + this.rootURL = pattern; + writeable = false; + } else { + File config = new File(rootPath); + if (!config.exists()) { + log.error ("Config path does not exist: {}", config); + throw new IllegalStateException(rootPath + " : config path does not exist"); + } + + writeable = Files.isWritable(config.toPath()); + this.rootURL = FILEPATH_PATTERN + DynamicConfigHelpers.formatFilePath(config.getAbsolutePath()); + } + + this.rootPath = rootPath; + } + + /** + * Load resources from the filesystem/classpath. + * @return A map from the path to the resource. + * @throws IOException If something goes boom. + */ + public Map loadResources() throws IOException { + Map resourceMap = new LinkedHashMap<>(); + int configDirURILength = resolver.getResources(this.rootURL)[0].getURI().toString().length(); + + Resource[] hjsonResources = resolver.getResources(this.rootURL + HJSON_EXTN); + for (Resource resource : hjsonResources) { + if (! resource.exists()) { + log.error("Missing resource during HJSON configuration load: {}", resource.getURI()); + continue; + } + String path = resource.getURI().toString().substring(configDirURILength); + resourceMap.put(path, ConfigFile.builder() + .type(toType(path)) + .contentProvider(() -> CONTENT_PROVIDER.apply(resource)) + .path(path) + .version(NO_VERSION) + .build()); + } + + return resourceMap; + } + + /** + * Load a single resource from the filesystem/classpath. + * @return The file content. + * @throws IOException If something goes boom. + */ + public ConfigFile loadResource(String relativePath) throws IOException { + Resource[] hjsonResources = resolver.getResources(this.rootURL + relativePath); + if (hjsonResources.length == 0 || ! hjsonResources[0].exists()) { + return null; + } + + return ConfigFile.builder() + .type(toType(relativePath)) + .contentProvider(() -> CONTENT_PROVIDER.apply(hjsonResources[0])) + .path(relativePath) + .version(NO_VERSION) + .build(); + } + + /** + * Remove src/.../resources/ from class path for configs directory. + * @param filePath class path for configs directory. + * @return formatted class path for configs directory. + */ + static String formatClassPath(String filePath) { + if (filePath.indexOf(RESOURCES + "/") > -1) { + return filePath.substring(filePath.indexOf(RESOURCES + "/") + RESOURCES_LENGTH + 1); + } else if (filePath.indexOf(RESOURCES) > -1) { + return filePath.substring(filePath.indexOf(RESOURCES) + RESOURCES_LENGTH); + } + return filePath; + } + + public static ConfigFile.ConfigFileType toType(String path) { + String lowerCasePath = path.toLowerCase(Locale.ROOT); + if (lowerCasePath.endsWith("db/variables.hjson")) { + return ConfigFile.ConfigFileType.VARIABLE; + } else if (lowerCasePath.endsWith("models/variables.hjson")) { + return ConfigFile.ConfigFileType.VARIABLE; + } else if (lowerCasePath.equals("models/security.hjson")) { + return ConfigFile.ConfigFileType.SECURITY; + } else if (DB_FILE.matcher(lowerCasePath).matches()) { + return ConfigFile.ConfigFileType.DATABASE; + } else if (TABLE_FILE.matcher(lowerCasePath).matches()) { + return ConfigFile.ConfigFileType.TABLE; + } else if (NAME_SPACE_FILE.matcher(lowerCasePath).matches()) { + return ConfigFile.ConfigFileType.NAMESPACE; + } else { + return ConfigFile.ConfigFileType.UNKNOWN; + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldTypeFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldTypeFormatAttr.java index 8138c357d4..4c7584dd35 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldTypeFormatAttr.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/ElideFieldTypeFormatAttr.java @@ -21,16 +21,20 @@ *

*/ public class ElideFieldTypeFormatAttr extends AbstractFormatAttribute { - private static final Pattern FIELD_TYPE_PATTERN = - Pattern.compile("^(?i)(Integer|Decimal|Money|Text|Coordinate|Boolean)$"); + public static final Pattern FIELD_TYPE_PATTERN = + Pattern.compile("^(?i)(Integer|Decimal|Money|Text|Coordinate|Boolean|Enum_Text|Enum_Ordinal)$"); public static final String FORMAT_NAME = "elideFieldType"; public static final String TYPE_KEY = "elideFieldType.error.enum"; public static final String TYPE_MSG = "Field type [%s] is not allowed. Supported value is one of " - + "[Integer, Decimal, Money, Text, Coordinate, Boolean]."; + + "[Integer, Decimal, Money, Text, Coordinate, Boolean, Enum_Text, Enum_Ordinal]."; public ElideFieldTypeFormatAttr() { - super(FORMAT_NAME, NodeType.STRING); + this(FORMAT_NAME); + } + + public ElideFieldTypeFormatAttr(String formatName) { + super(formatName, NodeType.STRING); } @Override diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameFormatAttr.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameFormatAttr.java index 150d8a308c..268186d356 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameFormatAttr.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/jsonformats/JavaClassNameFormatAttr.java @@ -22,7 +22,7 @@ */ public class JavaClassNameFormatAttr extends AbstractFormatAttribute { private static final String ID_PATTERN = "\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*"; - private static final Pattern CLASS_NAME_FORMAT_PATTERN = Pattern.compile(ID_PATTERN + "(\\." + ID_PATTERN + ")*"); + public static final Pattern CLASS_NAME_FORMAT_PATTERN = Pattern.compile(ID_PATTERN + "(\\." + ID_PATTERN + ")*"); public static final String FORMAT_NAME = "javaClassName"; public static final String FORMAT_KEY = "javaClassName.error.format"; diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Argument.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Argument.java index 94d6e12237..20fc8016d2 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Argument.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Argument.java @@ -36,6 +36,7 @@ @NoArgsConstructor @Builder public class Argument implements Named { + private static final long serialVersionUID = -6628282044575311784L; @JsonProperty("name") private String name; @@ -44,7 +45,7 @@ public class Argument implements Named { private String description; @JsonProperty("type") - private Type type; + private String type; @JsonProperty("values") @JsonDeserialize(as = LinkedHashSet.class) diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Dimension.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Dimension.java index d639e4301d..06f6cff927 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Dimension.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Dimension.java @@ -49,6 +49,7 @@ @NoArgsConstructor @Builder public class Dimension implements Named { + private static final long serialVersionUID = 7886036651874169795L; @JsonProperty("name") private String name; @@ -63,9 +64,11 @@ public class Dimension implements Named { private String category; @JsonProperty("hidden") + @Builder.Default private Boolean hidden = false; @JsonProperty("readAccess") + @Builder.Default private String readAccess = "Prefab.Role.All"; @JsonProperty("definition") @@ -75,7 +78,7 @@ public class Dimension implements Named { private String cardinality; @JsonProperty("type") - private Type type; + private String type; @JsonProperty("grains") @Singular diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Grain.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Grain.java index 0b9d821a33..07c1bf9473 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Grain.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Grain.java @@ -14,6 +14,8 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import java.io.Serializable; + /** * Grain can have SQL expressions that can substitute column * with the dimension definition expression. @@ -28,8 +30,8 @@ @AllArgsConstructor @NoArgsConstructor @Builder -public class Grain { - +public class Grain implements Serializable { + private static final long serialVersionUID = -6253818551445562327L; @JsonProperty("type") private Grain.GrainType type; diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Join.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Join.java index 55627f1c73..df910331d6 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Join.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Join.java @@ -34,6 +34,7 @@ @NoArgsConstructor @Builder public class Join implements Named { + private static final long serialVersionUID = -1416294756711914111L; @JsonProperty("name") private String name; diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Measure.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Measure.java index 0e724d1232..414d47ae76 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Measure.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Measure.java @@ -45,6 +45,7 @@ @NoArgsConstructor @Builder public class Measure implements Named { + private static final long serialVersionUID = 4404642046984907827L; @JsonProperty("name") private String name; @@ -59,16 +60,18 @@ public class Measure implements Named { private String category; @JsonProperty("hidden") + @Builder.Default private Boolean hidden = false; @JsonProperty("readAccess") + @Builder.Default private String readAccess = "Prefab.Role.All"; @JsonProperty("definition") private String definition; @JsonProperty("type") - private Type type; + private String type; @JsonProperty("maker") private String maker; diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Named.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Named.java index a0022c5fbc..0c770c0601 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Named.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Named.java @@ -5,9 +5,10 @@ */ package com.yahoo.elide.modelconfig.model; +import java.io.Serializable; import java.util.Collection; -public interface Named { +public interface Named extends Serializable { /** * Get the name local to its parent. * @return the local name diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/NamespaceConfig.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/NamespaceConfig.java index d14358c8ca..ba70cc32d5 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/NamespaceConfig.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/NamespaceConfig.java @@ -32,6 +32,7 @@ @NoArgsConstructor @Builder public class NamespaceConfig implements Named { + private static final long serialVersionUID = 279959092479649876L; public static String DEFAULT = "default"; diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Table.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Table.java index b7fd5ec2b4..da4dd0d670 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Table.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Table.java @@ -46,6 +46,7 @@ "arguments", "extend", "sql", + "maker", "table", "dbConnectionName", "filterTemplate" @@ -56,6 +57,8 @@ @NoArgsConstructor @Builder public class Table implements Named { + private static final long serialVersionUID = -7537337382856372741L; + @JsonProperty("name") private String name; @@ -128,6 +131,9 @@ public class Table implements Named { @JsonProperty("sql") private String sql; + @JsonProperty("maker") + private String maker; + @JsonProperty("table") private String table; diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/TableSource.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/TableSource.java index c0500100c1..9e9f3148d1 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/TableSource.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/TableSource.java @@ -16,6 +16,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import java.io.Serializable; import java.util.LinkedHashSet; import java.util.Set; @@ -35,7 +36,8 @@ @AllArgsConstructor @NoArgsConstructor @Builder -public class TableSource { +public class TableSource implements Serializable { + private static final long serialVersionUID = 5721654374755116755L; @JsonProperty("table") private String table; diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Type.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Type.java index 54a48fc08c..0ad2a7873a 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Type.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/model/Type.java @@ -8,24 +8,14 @@ /** * Data Type of the field. */ -public enum Type { - - TIME("TIME"), - INTEGER("INTEGER"), - DECIMAL("DECIMAL"), - MONEY("MONEY"), - TEXT("TEXT"), - COORDINATE("COORDINATE"), - BOOLEAN("BOOLEAN"); - - private final String value; - - private Type(String value) { - this.value = value; - } - - @Override - public String toString() { - return this.value; - } +public class Type { + public final static String TIME = "TIME"; + public final static String INTEGER = "INTEGER"; + public final static String DECIMAL = "DECIMAL"; + public final static String MONEY = "MONEY"; + public final static String TEXT = "TEXT"; + public final static String ENUM_ORDINAL = "ENUM_ORDINAL"; + public final static String ENUM_TEXT = "ENUM_TEXT"; + public final static String COORDINATE = "COORDINATE"; + public final static String BOOLEAN = "BOOLEAN"; } diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStore.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStore.java new file mode 100644 index 0000000000..f681055840 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStore.java @@ -0,0 +1,48 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store; + +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.modelconfig.io.FileLoader; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; +import com.yahoo.elide.modelconfig.validator.Validator; + +/** + * Elide DataStore which loads/persists HJSON configuration files as Elide models. + */ +public class ConfigDataStore implements DataStore { + + public static final String VALIDATE_ONLY_HEADER = "ValidateOnly"; + + private final FileLoader fileLoader; + private final Validator validator; + + public ConfigDataStore( + String configRoot, + Validator validator + ) { + this.fileLoader = new FileLoader(configRoot); + this.validator = validator; + } + + @Override + public void populateEntityDictionary(EntityDictionary dictionary) { + dictionary.bindEntity(ClassType.of(ConfigFile.class)); + } + + @Override + public ConfigDataStoreTransaction beginTransaction() { + return new ConfigDataStoreTransaction(fileLoader, false, validator); + } + + @Override + public ConfigDataStoreTransaction beginReadTransaction() { + return new ConfigDataStoreTransaction(fileLoader, true, validator); + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTransaction.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTransaction.java new file mode 100644 index 0000000000..9a56c785a8 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTransaction.java @@ -0,0 +1,250 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store; + +import static com.yahoo.elide.modelconfig.store.ConfigDataStore.VALIDATE_ONLY_HEADER; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.datastore.DataStoreIterableBuilder; +import com.yahoo.elide.core.datastore.DataStoreTransaction; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.modelconfig.io.FileLoader; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; +import com.yahoo.elide.modelconfig.validator.Validator; + +import org.apache.commons.io.FileUtils; + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * Elide DataStoreTransaction which loads/persists HJSON configuration files as Elide models. + */ +@Slf4j +public class ConfigDataStoreTransaction implements DataStoreTransaction { + private final FileLoader fileLoader; + private final Set todo; + private final Set dirty; + private final Set deleted; + private final Validator validator; + private final boolean readOnly; + + public ConfigDataStoreTransaction( + FileLoader fileLoader, + boolean readOnly, + Validator validator + ) { + this.fileLoader = fileLoader; + this.readOnly = readOnly || !fileLoader.isWriteable(); + this.dirty = new LinkedHashSet<>(); + this.deleted = new LinkedHashSet<>(); + this.todo = new LinkedHashSet<>(); + this.validator = validator; + } + + @Override + public void save(T entity, RequestScope scope) { + ConfigFile file = (ConfigFile) entity; + + boolean canWrite; + if (scope.isNewResource(file)) { + canWrite = canCreate(file.getPath()); + } else { + canWrite = canModify(file.getPath()); + } + + if (readOnly || !canWrite) { + log.error("Attempt to modify a read only configuration"); + throw new UnsupportedOperationException("Configuration is read only."); + } + + dirty.add(file); + todo.add(() -> updateFile(file.getPath(), file.getContent())); + } + + @Override + public void delete(T entity, RequestScope scope) { + ConfigFile file = (ConfigFile) entity; + if (readOnly || !canModify(file.getPath())) { + log.error("Attempt to modify a read only configuration"); + throw new UnsupportedOperationException("Configuration is read only."); + } + dirty.add(file); + deleted.add(file.getPath()); + todo.add(() -> deleteFile(file.getPath())); + } + + @Override + public void flush(RequestScope scope) { + if (!readOnly) { + Map resources; + try { + resources = fileLoader.loadResources(); + } catch (IOException e) { + log.error("Error reading configuration resources: {}", e.getMessage()); + throw new IllegalStateException(e); + } + + for (ConfigFile file : dirty) { + resources.put(file.getPath(), file); + } + + for (String path: deleted) { + resources.remove(path); + } + + try { + validator.validate(resources); + } catch (Exception e) { + log.error("Error validating configuration: {}", e.getMessage()); + throw new BadRequestException(e.getMessage()); + } + } + } + + @Override + public void commit(RequestScope scope) { + boolean validateOnly = scope.getRequestHeaderByName(VALIDATE_ONLY_HEADER) != null; + + if (! validateOnly) { + for (Runnable runnable : todo) { + runnable.run(); + } + } + } + + @Override + public void createObject(T entity, RequestScope scope) { + ConfigFile file = (ConfigFile) entity; + + if (readOnly || !canCreate(file.getPath())) { + log.error("Attempt to modify a read only configuration"); + throw new UnsupportedOperationException("Configuration is read only."); + } + dirty.add(file); + todo.add(() -> { + + //We have to assign the ID here during commit so it gets sent back in the response. + file.setId(ConfigFile.toId(file.getPath(), file.getVersion())); + + createFile(file.getPath()); + updateFile(file.getPath(), file.getContent()); + }); + } + + @Override + public T loadObject(EntityProjection entityProjection, Serializable id, RequestScope scope) { + String path = ConfigFile.fromId(id.toString()); + + try { + return (T) fileLoader.loadResource(path); + } catch (IOException e) { + log.error("Error reading configuration resources for {} : {}", id, e.getMessage()); + return null; + } + } + + @Override + public DataStoreIterable loadObjects(EntityProjection entityProjection, RequestScope scope) { + try { + Map resources = fileLoader.loadResources(); + + return new DataStoreIterableBuilder(resources.values()).allInMemory().build(); + } catch (IOException e) { + log.error("Error reading configuration resources: {}", e.getMessage()); + throw new IllegalStateException(e); + } + } + + @Override + public void cancel(RequestScope scope) { + todo.clear(); + dirty.clear(); + deleted.clear(); + } + + @Override + public void close() throws IOException { + //NOOP + } + + private boolean canCreate(String filePath) { + Path path = Path.of(fileLoader.getRootPath(), filePath); + File directory = path.toFile().getParentFile(); + + while (directory != null && !directory.exists()) { + directory = directory.getParentFile(); + } + + return (directory != null && Files.isWritable(directory.toPath())); + } + + private boolean canModify(String filePath) { + Path path = Path.of(fileLoader.getRootPath(), filePath); + File file = path.toFile(); + return !file.exists() || Files.isWritable(file.toPath()); + } + + private void deleteFile(String path) { + Path deletePath = Path.of(fileLoader.getRootPath(), path); + File file = deletePath.toFile(); + + if (! file.exists()) { + return; + } + + if (! file.delete()) { + log.error("Error deleting file: {}", file.getPath()); + throw new IllegalStateException("Unable to delete: " + path); + } + } + + private void updateFile(String path, String content) { + Path updatePath = Path.of(fileLoader.getRootPath(), path); + File file = updatePath.toFile(); + + try { + FileUtils.writeStringToFile(file, content, StandardCharsets.UTF_8); + } catch (IOException e) { + log.error("Error updating file: {} with message: {}", file.getPath(), e.getMessage()); + throw new IllegalStateException(e); + } + } + private void createFile(String path) { + Path createPath = Path.of(fileLoader.getRootPath(), path); + File file = createPath.toFile(); + + if (file.exists()) { + log.debug("File already exits: {}", file.getPath()); + return; + } + + try { + File parentDirectory = file.getParentFile(); + Files.createDirectories(Path.of(parentDirectory.getPath())); + + boolean created = file.createNewFile(); + if (!created) { + log.error("Unable to create file: {}", file.getPath()); + throw new IllegalStateException("Unable to create file: " + path); + } + } catch (IOException e) { + log.error("Error creating file: {} with message: {}", file.getPath(), e.getMessage()); + throw new IllegalStateException(e); + } + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigChecks.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigChecks.java new file mode 100644 index 0000000000..ccff7f56e1 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigChecks.java @@ -0,0 +1,45 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store.models; + +import com.yahoo.elide.core.security.checks.prefab.Role; + +/** + * Utility class which contains a set of check labels. Clients need to define checks for these + * labels and bind them to the dictionary at boot. + */ +public class ConfigChecks { + public static final String CAN_READ_CONFIG = "Can Read Config"; + public static final String CAN_UPDATE_CONFIG = "Can Update Config"; + public static final String CAN_DELETE_CONFIG = "Can Delete Config"; + public static final String CAN_CREATE_CONFIG = "Can Create Config"; + + public static class CanNotRead extends Role.NONE { + + }; + public static class CanNotUpdate extends Role.NONE { + + }; + public static class CanNotCreate extends Role.NONE { + + }; + public static class CanNotDelete extends Role.NONE { + + }; + public static class CanRead extends Role.ALL { + + }; + public static class CanUpdate extends Role.ALL { + + }; + public static class CanCreate extends Role.ALL { + + }; + public static class CanDelete extends Role.ALL { + + }; +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigFile.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigFile.java new file mode 100644 index 0000000000..0aee3b77c0 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/store/models/ConfigFile.java @@ -0,0 +1,147 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store.models; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static com.yahoo.elide.core.security.checks.prefab.Role.NONE_ROLE; +import com.yahoo.elide.annotation.ComputedAttribute; +import com.yahoo.elide.annotation.CreatePermission; +import com.yahoo.elide.annotation.DeletePermission; +import com.yahoo.elide.annotation.Exclude; +import com.yahoo.elide.annotation.Include; +import com.yahoo.elide.annotation.ReadPermission; +import com.yahoo.elide.annotation.UpdatePermission; +import com.yahoo.elide.core.exceptions.BadRequestException; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.Base64; +import java.util.Objects; +import java.util.function.Supplier; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +/** + * Represents an HJSON configuration file for dynamic Elide models. + */ +@Include(name = "config") +@Data +@NoArgsConstructor +@ReadPermission(expression = ConfigChecks.CAN_READ_CONFIG) +@UpdatePermission(expression = ConfigChecks.CAN_UPDATE_CONFIG) +@DeletePermission(expression = ConfigChecks.CAN_DELETE_CONFIG) +@CreatePermission(expression = ConfigChecks.CAN_CREATE_CONFIG) +public class ConfigFile { + + public enum ConfigFileType { + NAMESPACE, + TABLE, + VARIABLE, + DATABASE, + SECURITY, + UNKNOWN; + } + + @Id + @GeneratedValue + private String id; //Base64 encoded path-version + + @UpdatePermission(expression = NONE_ROLE) + private String path; + + private String version; + + @Exclude + private Supplier contentProvider; + + @Exclude + private String content; + + @ComputedAttribute + public String getContent() { + if (content == null) { + if (contentProvider == null) { + return null; + } + content = contentProvider.get(); + } + + return content; + } + + private ConfigFileType type; + + @Builder + public ConfigFile( + String path, + String version, + ConfigFileType type, + Supplier contentProvider) { + + this.id = toId(path, version); + + this.path = path; + if (version == null) { + this.version = NO_VERSION; + } else { + this.version = version; + } + this.contentProvider = contentProvider; + this.type = type; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + ConfigFile that = (ConfigFile) o; + return Objects.equals(path, that.path) && Objects.equals(version, that.version); + } + + @Override + public int hashCode() { + return Objects.hash(path, version); + } + + public static String toId(String path, String version) { + String id; + if (version == null || version.isEmpty()) { + id = path; + } else { + id = path + "-" + version; + } + return Base64.getEncoder().encodeToString(id.getBytes()); + } + + public static String fromId(String id) { + String idString; + try { + idString = URLDecoder.decode(id, "UTF-8"); + idString = new String(Base64.getDecoder().decode(idString.getBytes())); + } catch (IllegalArgumentException | UnsupportedEncodingException e) { + throw new BadRequestException("Invalid ID: " + id); + } + + int hyphenIndex = idString.lastIndexOf(".hjson-"); + + String path; + if (hyphenIndex < 0) { + path = idString; + } else { + path = idString.substring(0, idString.lastIndexOf('-')); + } + + return path; + } +} diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidator.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidator.java index 11c2d44ace..96aa43d4fe 100644 --- a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidator.java +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidator.java @@ -6,13 +6,13 @@ package com.yahoo.elide.modelconfig.validator; import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; -import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.SecurityCheck; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.dictionary.EntityPermissions; +import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.security.checks.Check; import com.yahoo.elide.core.security.checks.FilterExpressionCheck; import com.yahoo.elide.core.security.checks.UserCheck; @@ -23,6 +23,7 @@ import com.yahoo.elide.modelconfig.DynamicConfigHelpers; import com.yahoo.elide.modelconfig.DynamicConfigSchemaValidator; import com.yahoo.elide.modelconfig.DynamicConfiguration; +import com.yahoo.elide.modelconfig.io.FileLoader; import com.yahoo.elide.modelconfig.model.Argument; import com.yahoo.elide.modelconfig.model.DBConfig; import com.yahoo.elide.modelconfig.model.Dimension; @@ -37,6 +38,7 @@ import com.yahoo.elide.modelconfig.model.NamespaceConfig; import com.yahoo.elide.modelconfig.model.Table; import com.yahoo.elide.modelconfig.model.TableSource; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; import org.antlr.v4.runtime.tree.ParseTree; import org.apache.commons.cli.CommandLine; import org.apache.commons.cli.DefaultParser; @@ -44,13 +46,9 @@ import org.apache.commons.cli.Option; import org.apache.commons.cli.Options; import org.apache.commons.collections.CollectionUtils; -import org.apache.commons.io.IOUtils; -import org.springframework.core.io.Resource; -import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -73,7 +71,7 @@ /** * Util class to validate and parse the config files. Optionally compiles config files. */ -public class DynamicConfigValidator implements DynamicConfiguration { +public class DynamicConfigValidator implements DynamicConfiguration, Validator { private static final Set SQL_DISALLOWED_WORDS = new HashSet<>( Arrays.asList("DROP", "TRUNCATE", "DELETE", "INSERT", "UPDATE", "ALTER", "COMMENT", "CREATE", "DESCRIBE", @@ -93,42 +91,20 @@ public class DynamicConfigValidator implements DynamicConfiguration { private Map dbVariables; @Getter private final ElideDBConfig elideSQLDBConfig = new ElideSQLDBConfig(); @Getter private final ElideNamespaceConfig elideNamespaceConfig = new ElideNamespaceConfig(); - private final String configDir; private final DynamicConfigSchemaValidator schemaValidator = new DynamicConfigSchemaValidator(); - private final Map resourceMap = new HashMap<>(); - private final PathMatchingResourcePatternResolver resolver; private final EntityDictionary dictionary; + private final FileLoader fileLoader; private static final Pattern FILTER_VARIABLE_PATTERN = Pattern.compile(".*?\\{\\{(\\w+)\\}\\}"); public DynamicConfigValidator(ClassScanner scanner, String configDir) { dictionary = EntityDictionary.builder().scanner(scanner).build(); - resolver = new PathMatchingResourcePatternResolver(this.getClass().getClassLoader()); - - String pattern = CLASSPATH_PATTERN + DynamicConfigHelpers.formatFilePath(formatClassPath(configDir)); - - boolean classPathExists = false; - try { - classPathExists = (resolver.getResources(pattern).length != 0); - } catch (IOException e) { - //NOOP - } - - if (classPathExists) { - this.configDir = pattern; - } else { - File config = new File(configDir); - if (! config.exists()) { - throw new IllegalStateException(configDir + " : config path does not exist"); - } - this.configDir = FILEPATH_PATTERN + DynamicConfigHelpers.formatFilePath(config.getAbsolutePath()); - } + fileLoader = new FileLoader(configDir); initialize(); } private void initialize() { - Set> annotatedClasses = dictionary.getScanner().getAnnotatedClasses(Arrays.asList(Include.class, SecurityCheck.class)); @@ -171,36 +147,74 @@ public static void main(String[] args) { } } + @Override + public void validate(Map resourceMap) { + + resourceMap.forEach((path, file) -> { + if (file.getContent() == null || file.getContent().isEmpty()) { + throw new BadRequestException(String.format("Null or empty file content for %s", file.getPath())); + } + + //Validate that all the files are ones we know about and are safe to manipulate... + if (file.getType().equals(ConfigFile.ConfigFileType.UNKNOWN)) { + throw new BadRequestException(String.format("Unrecognized File: %s", file.getPath())); + } + + if (path.contains("..")) { + throw new BadRequestException(String.format("Parent directory traversal not allowed: %s", + file.getPath())); + } + + //Validate that the file types and file paths match... + if (! file.getType().equals(FileLoader.toType(path))) { + throw new BadRequestException(String.format("File type %s does not match file path: %s", + file.getType(), file.getPath())); + } + }); + + readConfigs(resourceMap); + validateConfigs(); + } + /** * Read and validate config files under config directory. * @throws IOException IOException */ public void readAndValidateConfigs() throws IOException { - readConfigs(); - validateConfigs(); + Map loadedFiles = fileLoader.loadResources(); + + validate(loadedFiles); } public void readConfigs() throws IOException { - this.loadConfigMap(); - this.modelVariables = readVariableConfig(Config.MODELVARIABLE); - this.elideSecurityConfig = readSecurityConfig(); - this.dbVariables = readVariableConfig(Config.DBVARIABLE); - this.elideSQLDBConfig.setDbconfigs(readDbConfig()); - this.elideTableConfig.setTables(readTableConfig()); - this.elideNamespaceConfig.setNamespaceconfigs(readNamespaceConfig()); + readConfigs(fileLoader.loadResources()); + } + + public void readConfigs(Map resourceMap) { + this.modelVariables = readVariableConfig(Config.MODELVARIABLE, resourceMap); + this.elideSecurityConfig = readSecurityConfig(resourceMap); + this.dbVariables = readVariableConfig(Config.DBVARIABLE, resourceMap); + this.elideSQLDBConfig.setDbconfigs(readDbConfig(resourceMap)); + this.elideTableConfig.setTables(readTableConfig(resourceMap)); + this.elideNamespaceConfig.setNamespaceconfigs(readNamespaceConfig(resourceMap)); populateInheritance(this.elideTableConfig); } - public void validateConfigs() throws IOException { + public void validateConfigs() { validateSecurityConfig(); - validateRequiredConfigsProvided(); - validateNameUniqueness(this.elideSQLDBConfig.getDbconfigs(), "Multiple DB configs found with the same name: "); - validateNameUniqueness(this.elideTableConfig.getTables(), "Multiple Table configs found with the same name: "); - validateTableConfig(); - validateNameUniqueness(this.elideNamespaceConfig.getNamespaceconfigs(), - "Multiple Namespace configs found with the same name: "); - validateNamespaceConfig(); - validateJoinedTablesDBConnectionName(this.elideTableConfig); + boolean configurationExists = validateRequiredConfigsProvided(); + + if (configurationExists) { + validateNameUniqueness(this.elideSQLDBConfig.getDbconfigs(), + "Multiple DB configs found with the same name: "); + validateNameUniqueness(this.elideTableConfig.getTables(), + "Multiple Table configs found with the same name: "); + validateTableConfig(); + validateNameUniqueness(this.elideNamespaceConfig.getNamespaceconfigs(), + "Multiple Namespace configs found with the same name: "); + validateNamespaceConfig(); + validateJoinedTablesDBConnectionName(this.elideTableConfig); + } } @Override @@ -386,35 +400,21 @@ private List getInheritedArguments(Table table, List argumen return (List) getInheritedAttribute(action, arguments); } - /** - * Add all Hjson resources under configDir in resourceMap. - * @throws IOException - */ - private void loadConfigMap() throws IOException { - int configDirURILength = resolver.getResources(this.configDir)[0].getURI().toString().length(); - - Resource[] hjsonResources = resolver.getResources(this.configDir + HJSON_EXTN); - for (Resource resource : hjsonResources) { - this.resourceMap.put(resource.getURI().toString().substring(configDirURILength), resource); - } - } - /** * Read variable file config. * @param config Config Enum * @return Map A map containing all the variables if variable config exists else empty map */ - private Map readVariableConfig(Config config) { + private Map readVariableConfig(Config config, Map resourceMap) { - return this.resourceMap + return resourceMap .entrySet() .stream() .filter(entry -> entry.getKey().startsWith(config.getConfigPath())) .map(entry -> { try { - String content = IOUtils.toString(entry.getValue().getInputStream(), UTF_8); - return DynamicConfigHelpers.stringToVariablesPojo(entry.getValue().getFilename(), - content, schemaValidator); + return DynamicConfigHelpers.stringToVariablesPojo(entry.getKey(), + entry.getValue().getContent(), schemaValidator); } catch (IOException e) { throw new IllegalStateException(e); } @@ -426,17 +426,17 @@ private Map readVariableConfig(Config config) { /** * Read and validates security config file. */ - private ElideSecurityConfig readSecurityConfig() { + private ElideSecurityConfig readSecurityConfig(Map resourceMap) { - return this.resourceMap + return resourceMap .entrySet() .stream() .filter(entry -> entry.getKey().startsWith(Config.SECURITY.getConfigPath())) .map(entry -> { try { - String content = IOUtils.toString(entry.getValue().getInputStream(), UTF_8); + String content = entry.getValue().getContent(); validateConfigForMissingVariables(content, this.modelVariables); - return DynamicConfigHelpers.stringToElideSecurityPojo(entry.getValue().getFilename(), + return DynamicConfigHelpers.stringToElideSecurityPojo(entry.getKey(), content, this.modelVariables, schemaValidator); } catch (IOException e) { throw new IllegalStateException(e); @@ -450,17 +450,17 @@ private ElideSecurityConfig readSecurityConfig() { * Read and validates db config files. * @return Set Set of SQL DB Configs */ - private Set readDbConfig() { + private Set readDbConfig(Map resourceMap) { - return this.resourceMap + return resourceMap .entrySet() .stream() .filter(entry -> entry.getKey().startsWith(Config.SQLDBConfig.getConfigPath())) .map(entry -> { try { - String content = IOUtils.toString(entry.getValue().getInputStream(), UTF_8); + String content = entry.getValue().getContent(); validateConfigForMissingVariables(content, this.dbVariables); - return DynamicConfigHelpers.stringToElideDBConfigPojo(entry.getValue().getFilename(), + return DynamicConfigHelpers.stringToElideDBConfigPojo(entry.getKey(), content, this.dbVariables, schemaValidator); } catch (IOException e) { throw new IllegalStateException(e); @@ -474,17 +474,17 @@ private Set readDbConfig() { * Read and validates namespace config files. * @return Set Set of Namespace Configs */ - private Set readNamespaceConfig() { + private Set readNamespaceConfig(Map resourceMap) { - return this.resourceMap + return resourceMap .entrySet() .stream() .filter(entry -> entry.getKey().startsWith(Config.NAMESPACEConfig.getConfigPath())) .map(entry -> { try { - String content = IOUtils.toString(entry.getValue().getInputStream(), UTF_8); + String content = entry.getValue().getContent(); validateConfigForMissingVariables(content, this.modelVariables); - String fileName = entry.getValue().getFilename(); + String fileName = entry.getKey(); return DynamicConfigHelpers.stringToElideNamespaceConfigPojo(fileName, content, this.modelVariables, schemaValidator); } catch (IOException e) { @@ -498,17 +498,17 @@ private Set readNamespaceConfig() { /** * Read and validates table config files. */ - private Set
readTableConfig() { + private Set
readTableConfig(Map resourceMap) { - return this.resourceMap + return resourceMap .entrySet() .stream() .filter(entry -> entry.getKey().startsWith(Config.TABLE.getConfigPath())) .map(entry -> { try { - String content = IOUtils.toString(entry.getValue().getInputStream(), UTF_8); + String content = entry.getValue().getContent(); validateConfigForMissingVariables(content, this.modelVariables); - return DynamicConfigHelpers.stringToElideTablePojo(entry.getValue().getFilename(), + return DynamicConfigHelpers.stringToElideTablePojo(entry.getKey(), content, this.modelVariables, schemaValidator); } catch (IOException e) { throw new IllegalStateException(e); @@ -521,10 +521,8 @@ private Set
readTableConfig() { /** * Checks if neither Table nor DB config files provided. */ - private void validateRequiredConfigsProvided() { - if (this.elideTableConfig.getTables().isEmpty() && this.elideSQLDBConfig.getDbconfigs().isEmpty()) { - throw new IllegalStateException("Neither Table nor DB configs found under: " + this.configDir); - } + private boolean validateRequiredConfigsProvided() { + return !(this.elideTableConfig.getTables().isEmpty() && this.elideSQLDBConfig.getDbconfigs().isEmpty()); } /** @@ -855,20 +853,6 @@ private static void printHelp(Options options) { options); } - /** - * Remove src/.../resources/ from class path for configs directory. - * @param filePath class path for configs directory. - * @return formatted class path for configs directory. - */ - public static String formatClassPath(String filePath) { - if (filePath.indexOf(RESOURCES + "/") > -1) { - return filePath.substring(filePath.indexOf(RESOURCES + "/") + RESOURCES_LENGTH + 1); - } else if (filePath.indexOf(RESOURCES) > -1) { - return filePath.substring(filePath.indexOf(RESOURCES) + RESOURCES_LENGTH); - } - return filePath; - } - private boolean hasStaticField(String modelName, String version, String fieldName) { Type modelType = dictionary.getEntityClass(modelName, version); if (modelType == null) { diff --git a/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/Validator.java b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/Validator.java new file mode 100644 index 0000000000..098b4e5244 --- /dev/null +++ b/elide-model-config/src/main/java/com/yahoo/elide/modelconfig/validator/Validator.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.validator; + +import com.yahoo.elide.modelconfig.store.models.ConfigFile; + +import java.util.Map; + +/** + * Used to validate configuration. + */ +@FunctionalInterface +public interface Validator { + + /** + * Validate a full set of configurations. Throws an exception if there is an error. + * @param resourceMap Maps the path to the resource content. + */ + void validate(Map resourceMap); +} diff --git a/elide-model-config/src/main/resources/elideTableSchema.json b/elide-model-config/src/main/resources/elideTableSchema.json index 098cf87fe5..e3eb50549e 100644 --- a/elide-model-config/src/main/resources/elideTableSchema.json +++ b/elide-model-config/src/main/resources/elideTableSchema.json @@ -47,7 +47,8 @@ }, "required": [ "name", - "type" + "type", + "default" ], "validateArgumentProperties": true, "additionalProperties": false @@ -549,6 +550,27 @@ "dimensions" ] }, + { + "properties": { + "maker": { + "title": "Table SQL maker function", + "description": "JVM function to invoke to generate the SQL query which is used to populate the table.", + "type": "string", + "format": "javaClassName" + }, + "dbConnectionName": { + "title": "DB Connection Name", + "description": "The database connection name for this model.", + "type": "string", + "format": "elideName" + } + }, + "required": [ + "name", + "maker", + "dimensions" + ] + }, { "properties": { "schema": { diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidatorTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidatorTest.java index 4039efd796..7ec2d9c74f 100644 --- a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidatorTest.java +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/DynamicConfigSchemaValidatorTest.java @@ -96,7 +96,11 @@ public void testInvalidTableSchemaMultipleErrors(String resource) throws Excepti + "[ERROR]\n" + "object instance has properties which are not allowed by the schema: [\"name\"]\n" + "[ERROR]\n" - + "Instance[/tables/0/arguments/0/type] failed to validate against schema[/definitions/argument/properties/type]. Field type [Number] is not allowed. Supported value is one of [Integer, Decimal, Money, Text, Coordinate, Boolean].\n" + + "Instance[/tables/0/arguments/0] failed to validate against schema[/definitions/argument]. object has missing required properties ([\"default\"])\n" + + "[ERROR]\n" + + "Instance[/tables/0/arguments/0/type] failed to validate against schema[/definitions/argument/properties/type]. Field type [Number] is not allowed. Supported value is one of [Integer, Decimal, Money, Text, Coordinate, Boolean, Enum_Text, Enum_Ordinal].\n" + + "[ERROR]\n" + + "Instance[/tables/0/arguments/1] failed to validate against schema[/definitions/argument]. object has missing required properties ([\"default\"])\n" + "[ERROR]\n" + "Instance[/tables/0/arguments/1/name] failed to validate against schema[/definitions/argument/properties/name]. Argument name [Grain] is not allowed. Argument name cannot be 'grain'.\n" + "[ERROR]\n" @@ -108,7 +112,7 @@ public void testInvalidTableSchemaMultipleErrors(String resource) throws Excepti + " Instance[/tables/0/dimensions/0] failed to validate against schema[/definitions/dimension]. instance failed to match all required schemas (matched only 0 out of 2)\n" + " Instance[/tables/0/dimensions/0/cardinality] failed to validate against schema[/definitions/dimensionRef/properties/cardinality]. Cardinality type [Extra small] is not allowed. Supported value is one of [Tiny, Small, Medium, Large, Huge].\n" + " Instance[/tables/0/dimensions/0/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [id] is not allowed. Field name cannot be one of [id, sql]\n" - + " Instance[/tables/0/dimensions/0/type] failed to validate against schema[/definitions/dimension/allOf/1/properties/type]. Field type [Float] is not allowed. Supported value is one of [Integer, Decimal, Money, Text, Coordinate, Boolean].\n" + + " Instance[/tables/0/dimensions/0/type] failed to validate against schema[/definitions/dimension/allOf/1/properties/type]. Field type [Float] is not allowed. Supported value is one of [Integer, Decimal, Money, Text, Coordinate, Boolean, Enum_Text, Enum_Ordinal].\n" + " Instance[/tables/0/dimensions/0] failed to validate against schema[/definitions/timeDimension]. instance failed to match all required schemas (matched only 0 out of 2)\n" + " Instance[/tables/0/dimensions/0/cardinality] failed to validate against schema[/definitions/dimensionRef/properties/cardinality]. Cardinality type [Extra small] is not allowed. Supported value is one of [Tiny, Small, Medium, Large, Huge].\n" + " Instance[/tables/0/dimensions/0/name] failed to validate against schema[/definitions/dimensionRef/properties/name]. Field name [id] is not allowed. Field name cannot be one of [id, sql]\n" @@ -128,7 +132,7 @@ public void testInvalidTableSchemaMultipleErrors(String resource) throws Excepti + "[ERROR]\n" + "Instance[/tables/0/dimensions/2] failed to validate against schema[/properties/tables/items/properties/dimensions/items]. instance failed to match exactly one schema (matched 0 out of 2)\n" + " Instance[/tables/0/dimensions/2] failed to validate against schema[/definitions/dimension]. instance failed to match all required schemas (matched only 1 out of 2)\n" - + " Instance[/tables/0/dimensions/2/type] failed to validate against schema[/definitions/dimension/allOf/1/properties/type]. Field type [TIMEX] is not allowed. Supported value is one of [Integer, Decimal, Money, Text, Coordinate, Boolean].\n" + + " Instance[/tables/0/dimensions/2/type] failed to validate against schema[/definitions/dimension/allOf/1/properties/type]. Field type [TIMEX] is not allowed. Supported value is one of [Integer, Decimal, Money, Text, Coordinate, Boolean, Enum_Text, Enum_Ordinal].\n" + " Instance[/tables/0/dimensions/2] failed to validate against schema[/definitions/dimension]. Properties: [grains] are not allowed for dimensions.\n" + " Instance[/tables/0/dimensions/2] failed to validate against schema[/definitions/timeDimension]. instance failed to match all required schemas (matched only 1 out of 2)\n" + " Instance[/tables/0/dimensions/2/grains/0/type] failed to validate against schema[/definitions/timeDimension/allOf/1/properties/grains/items/properties/type]. Grain type [Days] is not allowed. Supported value is one of [Second, Minute, Hour, Day, IsoWeek, Week, Month, Quarter, Year].\n" diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/io/FileLoaderTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/io/FileLoaderTest.java new file mode 100644 index 0000000000..83777629a0 --- /dev/null +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/io/FileLoaderTest.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.io; + +import static com.yahoo.elide.modelconfig.io.FileLoader.formatClassPath; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; + +public class FileLoaderTest { + + @Test + public void testFormatClassPath() { + assertEquals("anydir", formatClassPath("src/test/resources/anydir")); + assertEquals("anydir/configs", formatClassPath("src/test/resources/anydir/configs")); + assertEquals("src/test/resourc", formatClassPath("src/test/resourc")); + assertEquals("", formatClassPath("src/test/resources/")); + assertEquals("", formatClassPath("src/test/resources")); + assertEquals("anydir/configs", formatClassPath("src/test/resourcesanydir/configs")); + } +} diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydratorTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydratorTest.java index 21bfaa6227..127e558fd2 100644 --- a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydratorTest.java +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/parser/handlebars/HandlebarsHydratorTest.java @@ -60,6 +60,7 @@ public class HandlebarsHydratorTest { + " table: Country\n" + " column: isoCode\n" + " }\n" + + " default: US\n" + " }\n" + " ]\n" + " joins: [\n" diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTest.java new file mode 100644 index 0000000000..df8a2805a4 --- /dev/null +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/store/ConfigDataStoreTest.java @@ -0,0 +1,502 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package com.yahoo.elide.modelconfig.store; + +import static com.yahoo.elide.core.dictionary.EntityDictionary.NO_VERSION; +import static com.yahoo.elide.modelconfig.store.ConfigDataStore.VALIDATE_ONLY_HEADER; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.DATABASE; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.NAMESPACE; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.SECURITY; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.TABLE; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType.VARIABLE; +import static com.yahoo.elide.modelconfig.store.models.ConfigFile.toId; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.yahoo.elide.core.RequestScope; +import com.yahoo.elide.core.datastore.DataStoreIterable; +import com.yahoo.elide.core.exceptions.BadRequestException; +import com.yahoo.elide.core.request.EntityProjection; +import com.yahoo.elide.core.type.ClassType; +import com.yahoo.elide.core.utils.DefaultClassScanner; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; +import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; +import com.yahoo.elide.modelconfig.validator.Validator; +import com.google.common.collect.Lists; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +@Slf4j +public class ConfigDataStoreTest { + + @Test + public void testLoadObjects() { + String configRoot = "src/test/resources/validator/valid"; + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigDataStoreTransaction tx = store.beginReadTransaction(); + RequestScope scope = mock(RequestScope.class); + + DataStoreIterable loaded = tx.loadObjects(EntityProjection.builder() + .type(ClassType.of(ConfigFile.class)).build(), scope); + + List configFiles = Lists.newArrayList(loaded.iterator()); + + Supplier contentProvider = () -> + "{\n" + + " dbconfigs:\n" + + " [\n" + + " {\n" + + " name: MyDB2Connection\n" + + " url: jdbc:db2:localhost:50000/testdb\n" + + " driver: COM.ibm.db2.jdbc.net.DB2Driver\n" + + " user: guestdb2\n" + + " dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.PrestoDBDialect\n" + + " propertyMap:\n" + + " {\n" + + " hibernate.show_sql: true\n" + + " hibernate.default_batch_fetch_size: 100.1\n" + + " hibernate.hbm2ddl.auto: create\n" + + " }\n" + + " }\n" + + " {\n" + + " name: MySQLConnection\n" + + " url: jdbc:mysql://localhost/testdb?serverTimezone=UTC\n" + + " driver: com.mysql.jdbc.Driver\n" + + " user: guestmysql\n" + + " dialect: com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.impl.HiveDialect\n" + + " }\n" + + " ]\n" + + "}\n"; + + assertEquals(10, configFiles.size()); + assertTrue(compare(ConfigFile.builder() + .version("") + .type(ConfigFile.ConfigFileType.DATABASE) + .contentProvider(contentProvider) + .path("db/sql/multiple_db_no_variables.hjson") + .build(), configFiles.get(1))); + + assertEquals("db/sql/multiple_db.hjson", configFiles.get(0).getPath()); + assertEquals(DATABASE, configFiles.get(0).getType()); + + assertEquals("db/sql/single_db.hjson", configFiles.get(2).getPath()); + assertEquals(DATABASE, configFiles.get(2).getType()); + + assertEquals("db/variables.hjson", configFiles.get(3).getPath()); + assertEquals(VARIABLE, configFiles.get(3).getType()); + + assertEquals("models/namespaces/player.hjson", configFiles.get(4).getPath()); + assertEquals(NAMESPACE, configFiles.get(4).getType()); + + assertEquals("models/security.hjson", configFiles.get(5).getPath()); + assertEquals(SECURITY, configFiles.get(5).getType()); + + assertEquals("models/tables/player_stats.hjson", configFiles.get(6).getPath()); + assertEquals(TABLE, configFiles.get(6).getType()); + + assertEquals("models/tables/player_stats_extends.hjson", configFiles.get(7).getPath()); + assertEquals(TABLE, configFiles.get(7).getType()); + + assertEquals("models/tables/referred_model.hjson", configFiles.get(8).getPath()); + assertEquals(TABLE, configFiles.get(8).getType()); + + assertEquals("models/variables.hjson", configFiles.get(9).getPath()); + assertEquals(VARIABLE, configFiles.get(9).getType()); + } + + @Test + public void testCreate(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigFile newFile = createFile("test", store, false); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + RequestScope scope = mock(RequestScope.class); + + ConfigFile loaded = readTx.loadObject(EntityProjection.builder().type(ClassType.of(ConfigFile.class)).build(), + toId("models/tables/test.hjson", NO_VERSION), scope); + + assertTrue(compare(newFile, loaded)); + } + + @Test + public void testCreateReadOnly() { + + //This path is read only (Classpath)... + String configRoot = "src/test/resources/validator/valid"; + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + assertThrows(UnsupportedOperationException.class, + () -> createFile("test", store, false)); + } + + @Test + public void testCreateInvalid(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + assertThrows(BadRequestException.class, + () -> createInvalidFile(configRoot, store)); + } + + @Test + public void testCreateValidateOnly(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + RequestScope scope = mock(RequestScope.class); + + ConfigFile loaded = readTx.loadObject(EntityProjection.builder().type(ClassType.of(ConfigFile.class)).build(), + toId("models/tables/test.hjson", NO_VERSION), scope); + + assertNull(loaded); + } + + @Test + public void testUpdate(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + createFile("test", store, false); + ConfigFile updateFile = updateFile(configRoot, store); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + RequestScope scope = mock(RequestScope.class); + + ConfigFile loaded = readTx.loadObject(EntityProjection.builder().type(ClassType.of(ConfigFile.class)).build(), + toId("models/tables/test.hjson", NO_VERSION), scope); + + assertTrue(compare(updateFile, loaded)); + } + + @Test + public void testUpdateWithPermissionError(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigFile createdFile = createFile("test", store, false); + + String createdFilePath = Path.of(configPath.toFile().getPath(), createdFile.getPath()).toFile().getPath(); + + File file = new File(createdFilePath); + boolean blockFailed = blockWrites(file); + + if (blockFailed) { + //We can't actually test because setting permissions isn't working. + return; + } + + assertThrows(UnsupportedOperationException.class, () -> updateFile(configRoot, store)); + } + + @Test + public void testDeleteWithPermissionError(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigFile createdFile = createFile("test", store, false); + + String createdFilePath = Path.of(configPath.toFile().getPath(), createdFile.getPath()).toFile().getPath(); + + File file = new File(createdFilePath); + boolean blockFailed = blockWrites(file); + + if (blockFailed) { + //We can't actually test because setting permissions isn't working. + return; + } + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + assertThrows(UnsupportedOperationException.class, () -> tx.delete(createdFile, scope)); + } + + @Test + public void testCreateWithPermissionError(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + File file = configPath.toFile(); + boolean blockFailed = blockWrites(file); + + if (blockFailed) { + //We can't actually test because setting permissions isn't working. + return; + } + + assertThrows(UnsupportedOperationException.class, () -> createFile("test", store, false)); + } + + @Test + public void testDelete(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + + ConfigFile newFile = createFile("test", store, false); + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + tx.delete(newFile, scope); + + tx.flush(scope); + tx.commit(scope); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + + ConfigFile loaded = readTx.loadObject(EntityProjection.builder().type(ClassType.of(ConfigFile.class)).build(), + toId("models/tables/test.hjson", NO_VERSION), scope); + + assertNull(loaded); + } + + @Test + public void testMultipleFileOperations(@TempDir Path configPath) { + String configRoot = configPath.toFile().getPath(); + + Validator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), configRoot); + ConfigDataStore store = new ConfigDataStore(configRoot, validator); + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + String [] tables = {"table1", "table2", "table3"}; + + for (String tableName : tables) { + Supplier contentProvider = () -> String.format("{ \n" + + " tables: [{ \n" + + " name: %s\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}", tableName); + + ConfigFile newFile = ConfigFile.builder() + .type(TABLE) + .contentProvider(contentProvider) + .path(String.format("models/tables/%s.hjson", tableName)) + .build(); + + + tx.createObject(newFile, scope); + } + + ConfigFile invalid = ConfigFile.builder().path("/tmp").contentProvider((() -> "Invalid")).build(); + tx.createObject(invalid, scope); + + tx.delete(invalid, scope); + + tx.flush(scope); + tx.commit(scope); + + ConfigDataStoreTransaction readTx = store.beginReadTransaction(); + DataStoreIterable loaded = readTx.loadObjects(EntityProjection.builder() + .type(ClassType.of(ConfigFile.class)).build(), scope); + + List configFiles = Lists.newArrayList(loaded.iterator()); + + assertEquals(3, configFiles.size()); + + assertEquals("models/tables/table1.hjson", configFiles.get(0).getPath()); + assertEquals(TABLE, configFiles.get(0).getType()); + + assertEquals("models/tables/table2.hjson", configFiles.get(1).getPath()); + assertEquals(TABLE, configFiles.get(1).getType()); + + assertEquals("models/tables/table3.hjson", configFiles.get(2).getPath()); + assertEquals(TABLE, configFiles.get(2).getType()); + } + + protected ConfigFile createFile(String tableName, ConfigDataStore store, boolean validateOnly) { + Supplier contentProvider = () -> String.format("{ \n" + + " tables: [{ \n" + + " name: %s\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}", tableName); + + ConfigFile newFile = ConfigFile.builder() + .type(TABLE) + .contentProvider(contentProvider) + .path(String.format("models/tables/%s.hjson", tableName)) + .build(); + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + if (validateOnly) { + when(scope.getRequestHeaderByName(eq(VALIDATE_ONLY_HEADER))).thenReturn("true"); + } + + tx.createObject(newFile, scope); + tx.save(newFile, scope); + tx.flush(scope); + tx.commit(scope); + + return newFile; + } + + protected ConfigFile updateFile(String configRoot, ConfigDataStore store) { + Supplier contentProvider = () -> "{ \n" + + " tables: [{ \n" + + " name: Test2\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + ConfigFile updatedFile = ConfigFile.builder() + .type(TABLE) + .contentProvider(contentProvider) + .path("models/tables/test.hjson") + .build(); + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + tx.save(updatedFile, scope); + tx.flush(scope); + tx.commit(scope); + + return updatedFile; + } + + protected ConfigFile createInvalidFile(String configRoot, ConfigDataStore store) { + Supplier contentProvider = () -> "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INVALID_TYPE\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + ConfigFile newFile = ConfigFile.builder() + .type(TABLE) + .contentProvider(contentProvider) + .path("models/tables/test.hjson") + .build(); + + ConfigDataStoreTransaction tx = store.beginTransaction(); + RequestScope scope = mock(RequestScope.class); + + tx.createObject(newFile, scope); + tx.save(newFile, scope); + tx.flush(scope); + tx.commit(scope); + + return newFile; + } + + protected boolean compare(ConfigFile a, ConfigFile b) { + return a.equals(b) && a.getContent().equals(b.getContent()) && a.getType().equals(b.getType()); + } + + protected boolean blockWrites(File file) { + Set perms = new HashSet<>(); + + try { + //Windows doesn't like this. + Files.setPosixFilePermissions(file.toPath(), perms); + } catch (Exception e) { + file.setWritable(false, false); + } + + return Files.isWritable(file.toPath()); + } +} diff --git a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidatorTest.java b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidatorTest.java index e3c0e86b98..b964d62fb9 100644 --- a/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidatorTest.java +++ b/elide-model-config/src/test/java/com/yahoo/elide/modelconfig/validator/DynamicConfigValidatorTest.java @@ -11,12 +11,16 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.yahoo.elide.core.exceptions.BadRequestException; import com.yahoo.elide.core.utils.DefaultClassScanner; import com.yahoo.elide.modelconfig.model.Argument; import com.yahoo.elide.modelconfig.model.Table; import com.yahoo.elide.modelconfig.model.Type; +import com.yahoo.elide.modelconfig.store.models.ConfigFile; import org.junit.jupiter.api.Test; +import java.util.Map; + public class DynamicConfigValidatorTest { @Test @@ -153,17 +157,6 @@ public void testMissingSecurityConfig() throws Exception { }); } - @Test - public void testMissingConfigs() throws Exception { - String error = tapSystemErr(() -> { - int exitStatus = catchSystemExit(() -> - DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/missing_configs"})); - assertEquals(2, exitStatus); - }); - - assertTrue(error.startsWith("Neither Table nor DB configs found under:")); - } - @Test public void testMissingTableConfig() throws Exception { tapSystemErr(() -> { @@ -241,7 +234,7 @@ public void testBadSecurityRoleConfig() throws Exception { assertEquals(2, exitStatus); }); - String expectedError = "Schema validation failed for: security.hjson\n" + String expectedError = "Schema validation failed for: models/security.hjson\n" + "[ERROR]\n" + "Instance[/roles/0] failed to validate against schema[/properties/roles/items]. Role [admin,] is not allowed. Role must start with an alphabetic character and can include alaphabets, numbers, spaces and '.' only.\n" + "[ERROR]\n" @@ -268,7 +261,7 @@ public void testNamespaceBadDefaultName() throws Exception { assertEquals(2, exitStatus); }); - String expected = "Schema validation failed for: test_namespace.hjson\n" + String expected = "Schema validation failed for: models/namespaces/test_namespace.hjson\n" + "[ERROR]\n" + "Instance[/namespaces/0/name] failed to validate against schema[/properties/namespaces/items/properties/name]. Name [Default] clashes with the 'default' namespace. Either change the case or pick a different namespace name.\n"; assertEquals(expected, error); @@ -303,7 +296,7 @@ public void testBadTableConfigJoinType() throws Exception { DynamicConfigValidator.main(new String[] { "--configDir", "src/test/resources/validator/bad_table_join_type"})); assertEquals(2, exitStatus); }); - String expected = "Schema validation failed for: table1.hjson\n" + String expected = "Schema validation failed for: models/tables/table1.hjson\n" + "[ERROR]\n" + "Instance[/tables/0/joins/0/kind] failed to validate against schema[/definitions/join/properties/kind]. Join kind [toAll] is not allowed. Supported value is one of [ToOne, ToMany].\n" + "[ERROR]\n" @@ -314,7 +307,7 @@ public void testBadTableConfigJoinType() throws Exception { @Test public void testBadDimName() throws Exception { - String expectedMessage = "Schema validation failed for: table1.hjson\n" + String expectedMessage = "Schema validation failed for: models/tables/table1.hjson\n" + "[ERROR]\n" + "Instance[/tables/0/dimensions/0] failed to validate against schema[/properties/tables/items/properties/dimensions/items]. instance failed to match exactly one schema (matched 0 out of 2)\n" + " Instance[/tables/0/dimensions/0] failed to validate against schema[/definitions/dimension]. instance failed to match all required schemas (matched only 1 out of 2)\n" @@ -462,12 +455,28 @@ public void testDuplicateArgumentNameInComplexTableFilter() throws Exception { } @Test - public void testFormatClassPath() { - assertEquals("anydir", DynamicConfigValidator.formatClassPath("src/test/resources/anydir")); - assertEquals("anydir/configs", DynamicConfigValidator.formatClassPath("src/test/resources/anydir/configs")); - assertEquals("src/test/resourc", DynamicConfigValidator.formatClassPath("src/test/resourc")); - assertEquals("", DynamicConfigValidator.formatClassPath("src/test/resources/")); - assertEquals("", DynamicConfigValidator.formatClassPath("src/test/resources")); - assertEquals("anydir/configs", DynamicConfigValidator.formatClassPath("src/test/resourcesanydir/configs")); + public void testPathAndConfigFileTypeMismatches() { + DynamicConfigValidator validator = new DynamicConfigValidator(DefaultClassScanner.getInstance(), + "src/test/resources/validator/valid"); + + Map resources = Map.of("blah/foo", + ConfigFile.builder().path("blah/foo").type(ConfigFile.ConfigFileType.SECURITY).build()); + + assertThrows(BadRequestException.class, () -> validator.validate(resources)); + + Map resources2 = Map.of("models/variables.hjson", + ConfigFile.builder().path("models/variables.hjson").type(ConfigFile.ConfigFileType.TABLE).build()); + + assertThrows(BadRequestException.class, () -> validator.validate(resources2)); + + Map resources3 = Map.of("models/tables/referred_model.hjson", + ConfigFile.builder().path("models/tables/referred_model.hjson").type(ConfigFile.ConfigFileType.NAMESPACE).build()); + + assertThrows(BadRequestException.class, () -> validator.validate(resources3)); + + Map resources4 = Map.of("models/tables/referred_model.hjson", + ConfigFile.builder().path("models/tables/referred_model.hjson").type(ConfigFile.ConfigFileType.DATABASE).build()); + + assertThrows(BadRequestException.class, () -> validator.validate(resources4)); } } diff --git a/elide-model-config/src/test/resources/validator/duplicate_column_args/models/tables/table.hjson b/elide-model-config/src/test/resources/validator/duplicate_column_args/models/tables/table.hjson index 0e56297245..8a6e6952a1 100644 --- a/elide-model-config/src/test/resources/validator/duplicate_column_args/models/tables/table.hjson +++ b/elide-model-config/src/test/resources/validator/duplicate_column_args/models/tables/table.hjson @@ -19,6 +19,7 @@ { name: foo type: TEXT + default: foobar } ] }] diff --git a/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats.hjson b/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats.hjson index 97f9cdc96e..ee687c99f9 100644 --- a/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats.hjson +++ b/elide-model-config/src/test/resources/validator/valid/models/tables/player_stats.hjson @@ -35,6 +35,7 @@ table: Country column: isoCode } + default: US } ] joins: [ diff --git a/elide-quarkus/deployment/pom.xml b/elide-quarkus/deployment/pom.xml index a583d94d05..a81c7b4612 100644 --- a/elide-quarkus/deployment/pom.xml +++ b/elide-quarkus/deployment/pom.xml @@ -5,7 +5,7 @@ com.yahoo.elide elide-quarkus-extension-parent - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT elide-quarkus-extension-deployment jar @@ -109,6 +109,27 @@ org.apache.maven.plugins maven-checkstyle-plugin + + org.apache.maven.plugins + maven-javadoc-plugin + 3.4.1 + + + attach-javadocs + + jar + + + + + + false + all,-missing + ${source.jdk.version} + ${delombok.output} + ${project.basedir}/src/main/java + + diff --git a/elide-quarkus/integration-tests/pom.xml b/elide-quarkus/integration-tests/pom.xml index e1d43882d2..c6311eb4c6 100644 --- a/elide-quarkus/integration-tests/pom.xml +++ b/elide-quarkus/integration-tests/pom.xml @@ -5,7 +5,7 @@ com.yahoo.elide elide-quarkus-extension-parent - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT elide-quarkus-extension-integration-tests Elide Quarkus Extension - Integration Tests diff --git a/elide-quarkus/pom.xml b/elide-quarkus/pom.xml index fe56b39a7f..e2e0759596 100644 --- a/elide-quarkus/pom.xml +++ b/elide-quarkus/pom.xml @@ -2,7 +2,7 @@ 4.0.0 - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-quarkus-extension-parent pom @@ -11,7 +11,7 @@ com.yahoo.elide elide-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -52,8 +52,8 @@ 11 UTF-8 UTF-8 - 2.2.1.Final - 6.0.0-pr1-SNAPSHOT + 2.14.3.Final + 6.1.10-SNAPSHOT 3.0.0-M5 ${project.basedir}/../.. diff --git a/elide-quarkus/runtime/pom.xml b/elide-quarkus/runtime/pom.xml index 2405c38542..3d4f956736 100644 --- a/elide-quarkus/runtime/pom.xml +++ b/elide-quarkus/runtime/pom.xml @@ -5,7 +5,7 @@ com.yahoo.elide elide-quarkus-extension-parent - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT elide-quarkus-extension Elide Quarkus Extension - Runtime diff --git a/elide-quarkus/runtime/src/main/java/com/yahoo/elide/extension/runtime/ElideBeans.java b/elide-quarkus/runtime/src/main/java/com/yahoo/elide/extension/runtime/ElideBeans.java index e197759227..46e76b0312 100644 --- a/elide-quarkus/runtime/src/main/java/com/yahoo/elide/extension/runtime/ElideBeans.java +++ b/elide-quarkus/runtime/src/main/java/com/yahoo/elide/extension/runtime/ElideBeans.java @@ -6,7 +6,6 @@ package com.yahoo.elide.extension.runtime; -import static com.yahoo.elide.datastores.jpa.JpaDataStore.DEFAULT_LOGGER; import com.yahoo.elide.Elide; import com.yahoo.elide.ElideSettingsBuilder; import com.yahoo.elide.core.audit.Slf4jLogger; @@ -60,8 +59,8 @@ public Elide produceElide( .withEntityDictionary(dictionary) .withDefaultMaxPageSize(config.defaultMaxPageSize) .withDefaultPageSize(config.defaultPageSize) - .withJoinFilterDialect(new RSQLFilterDialect(dictionary)) - .withSubqueryFilterDialect(new RSQLFilterDialect(dictionary)) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) .withAuditLogger(new Slf4jLogger()) .withBaseUrl(rootPath) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) @@ -113,7 +112,7 @@ public DataStore produceDataStore( DataStore store = new JpaDataStore( entityManagerFactory::createEntityManager, - em -> new NonJtaTransaction(em, txCancel, DEFAULT_LOGGER, true)); + em -> new NonJtaTransaction(em, txCancel)); store.populateEntityDictionary(dictionary); return store; diff --git a/elide-spring/elide-spring-boot-autoconfigure/pom.xml b/elide-spring/elide-spring-boot-autoconfigure/pom.xml index b8052691c0..8b0649aa00 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/pom.xml +++ b/elide-spring/elide-spring-boot-autoconfigure/pom.xml @@ -8,7 +8,7 @@ com.yahoo.elide elide-spring-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -48,49 +48,56 @@ com.yahoo.elide elide-core - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT true com.yahoo.elide elide-graphql - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT true com.yahoo.elide elide-datastore-aggregation - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT true com.yahoo.elide elide-datastore-jpa - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT + true + + + + com.yahoo.elide + elide-datastore-jms + 6.1.10-SNAPSHOT true com.yahoo.elide elide-swagger - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT true com.yahoo.elide elide-async - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT true com.yahoo.elide elide-model-config - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT true @@ -100,6 +107,12 @@ true + + org.springframework.boot + spring-boot-starter-websocket + true + + org.owasp.encoder encoder @@ -148,15 +161,15 @@ - org.springframework.boot - spring-boot-starter-actuator - ${spring.boot.version} + org.springframework.cloud + spring-cloud-context true + io.micrometer micrometer-core - 1.7.3 + 1.9.5 true @@ -173,7 +186,7 @@ com.yahoo.elide elide-test-helpers - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT test @@ -189,6 +202,25 @@ + + org.springframework.boot + spring-boot-starter-artemis + test + + + + org.springframework.boot + spring-boot-starter-actuator + test + + + + org.apache.activemq + artemis-jms-server + 2.26.0 + test + + com.h2database h2 @@ -208,16 +240,21 @@ - junit - junit + org.junit.jupiter + junit-jupiter-engine test + - org.junit.jupiter - junit-jupiter-engine + org.junit.platform + junit-platform-commons + 1.9.1 test + + diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/DynamicConfigProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/DynamicConfigProperties.java index e7e8b3e064..6c569f4096 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/DynamicConfigProperties.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/DynamicConfigProperties.java @@ -23,4 +23,8 @@ public class DynamicConfigProperties { */ private String path = "/"; + /** + * Enable support for reading and manipulating HJSON configuration through Elide models. + */ + private boolean configApiEnabled = false; } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAsyncConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAsyncConfiguration.java index 3d984292e7..3235dad49a 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAsyncConfiguration.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAsyncConfiguration.java @@ -6,10 +6,10 @@ package com.yahoo.elide.spring.config; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; -import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.POSTCOMMIT; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PREFLUSH; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRESECURITY; -import com.yahoo.elide.Elide; +import com.yahoo.elide.RefreshableElide; import com.yahoo.elide.async.export.formatter.CSVExportFormatter; import com.yahoo.elide.async.export.formatter.JSONExportFormatter; import com.yahoo.elide.async.export.formatter.TableExportFormatter; @@ -57,24 +57,30 @@ public class ElideAsyncConfiguration { * @param elide elideObject. * @param settings Elide settings. * @param asyncQueryDao AsyncDao object. - * @param dictionary EntityDictionary. * @return a AsyncExecutorService. */ @Bean @ConditionalOnMissingBean - public AsyncExecutorService buildAsyncExecutorService(Elide elide, ElideConfigProperties settings, - AsyncAPIDAO asyncQueryDao, EntityDictionary dictionary, - @Autowired(required = false) ResultStorageEngine resultStorageEngine) { + public AsyncExecutorService buildAsyncExecutorService( + RefreshableElide elide, + ElideConfigProperties settings, + AsyncAPIDAO asyncQueryDao, + @Autowired(required = false) ResultStorageEngine resultStorageEngine + ) { AsyncProperties asyncProperties = settings.getAsync(); ExecutorService executor = Executors.newFixedThreadPool(asyncProperties.getThreadPoolSize()); ExecutorService updater = Executors.newFixedThreadPool(asyncProperties.getThreadPoolSize()); - AsyncExecutorService asyncExecutorService = new AsyncExecutorService(elide, executor, updater, asyncQueryDao); + AsyncExecutorService asyncExecutorService = new AsyncExecutorService(elide.getElide(), executor, + updater, asyncQueryDao); // Binding AsyncQuery LifeCycleHook AsyncQueryHook asyncQueryHook = new AsyncQueryHook(asyncExecutorService, asyncProperties.getMaxAsyncAfterSeconds()); - dictionary.bindTrigger(AsyncQuery.class, READ, PRESECURITY, asyncQueryHook, false); + + EntityDictionary dictionary = elide.getElide().getElideSettings().getDictionary(); + + dictionary.bindTrigger(AsyncQuery.class, CREATE, PREFLUSH, asyncQueryHook, false); dictionary.bindTrigger(AsyncQuery.class, CREATE, POSTCOMMIT, asyncQueryHook, false); dictionary.bindTrigger(AsyncQuery.class, CREATE, PRESECURITY, asyncQueryHook, false); @@ -85,13 +91,13 @@ public AsyncExecutorService buildAsyncExecutorService(Elide elide, ElideConfigPr boolean skipCSVHeader = asyncProperties.getExport() != null && asyncProperties.getExport().isSkipCSVHeader(); Map supportedFormatters = new HashMap<>(); - supportedFormatters.put(ResultType.CSV, new CSVExportFormatter(elide, skipCSVHeader)); - supportedFormatters.put(ResultType.JSON, new JSONExportFormatter(elide)); + supportedFormatters.put(ResultType.CSV, new CSVExportFormatter(elide.getElide(), skipCSVHeader)); + supportedFormatters.put(ResultType.JSON, new JSONExportFormatter(elide.getElide())); // Binding TableExport LifeCycleHook TableExportHook tableExportHook = getTableExportHook(asyncExecutorService, settings, supportedFormatters, resultStorageEngine); - dictionary.bindTrigger(TableExport.class, READ, PRESECURITY, tableExportHook, false); + dictionary.bindTrigger(TableExport.class, CREATE, PREFLUSH, tableExportHook, false); dictionary.bindTrigger(TableExport.class, CREATE, POSTCOMMIT, tableExportHook, false); dictionary.bindTrigger(TableExport.class, CREATE, PRESECURITY, tableExportHook, false); } @@ -134,9 +140,10 @@ public void validateOptions(AsyncAPI export, RequestScope requestScope) { @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = "elide.async", name = "cleanupEnabled", matchIfMissing = false) - public AsyncCleanerService buildAsyncCleanerService(Elide elide, ElideConfigProperties settings, - AsyncAPIDAO asyncQueryDao) { - AsyncCleanerService.init(elide, settings.getAsync().getMaxRunTimeSeconds(), + public AsyncCleanerService buildAsyncCleanerService(RefreshableElide elide, + ElideConfigProperties settings, + AsyncAPIDAO asyncQueryDao) { + AsyncCleanerService.init(elide.getElide(), settings.getAsync().getMaxRunTimeSeconds(), settings.getAsync().getQueryCleanupDays(), settings.getAsync().getQueryCancellationIntervalSeconds(), asyncQueryDao); return AsyncCleanerService.getInstance(); @@ -150,22 +157,21 @@ public AsyncCleanerService buildAsyncCleanerService(Elide elide, ElideConfigProp @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = "elide.async", name = "defaultAsyncAPIDAO", matchIfMissing = true) - public AsyncAPIDAO buildAsyncAPIDAO(Elide elide) { - return new DefaultAsyncAPIDAO(elide.getElideSettings(), elide.getDataStore()); + public AsyncAPIDAO buildAsyncAPIDAO(RefreshableElide elide) { + return new DefaultAsyncAPIDAO(elide.getElide().getElideSettings(), elide.getElide().getDataStore()); } /** * Configure the ResultStorageEngine used by async query requests. - * @param elide elideObject. + * @param settings Elide settings. * @return an ResultStorageEngine object. */ @Bean @ConditionalOnMissingBean @ConditionalOnProperty(prefix = "elide.async.export", name = "enabled", matchIfMissing = false) - public ResultStorageEngine buildResultStorageEngine(Elide elide, ElideConfigProperties settings, - AsyncAPIDAO asyncQueryDAO) { + public ResultStorageEngine buildResultStorageEngine(ElideConfigProperties settings) { FileResultStorageEngine resultStorageEngine = new FileResultStorageEngine(settings.getAsync().getExport() - .getStorageDestination()); + .getStorageDestination(), settings.getAsync().getExport().isExtensionEnabled()); return resultStorageEngine; } } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java index 4cf0283618..3d2dcd10f9 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideAutoConfiguration.java @@ -6,16 +6,20 @@ package com.yahoo.elide.spring.config; import static com.yahoo.elide.datastores.jpa.JpaDataStore.DEFAULT_LOGGER; +import static org.springframework.beans.factory.config.BeanDefinition.SCOPE_PROTOTYPE; import com.yahoo.elide.Elide; import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.RefreshableElide; import com.yahoo.elide.async.models.AsyncQuery; import com.yahoo.elide.async.models.TableExport; +import com.yahoo.elide.core.TransactionRegistry; import com.yahoo.elide.core.audit.Slf4jLogger; import com.yahoo.elide.core.datastore.DataStore; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.dictionary.Injector; import com.yahoo.elide.core.exceptions.ErrorMapper; import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.core.security.checks.Check; import com.yahoo.elide.core.security.checks.prefab.Role; import com.yahoo.elide.core.type.ClassType; import com.yahoo.elide.core.type.Type; @@ -30,19 +34,27 @@ import com.yahoo.elide.datastores.aggregation.core.QueryLogger; import com.yahoo.elide.datastores.aggregation.core.Slf4jQueryLogger; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; import com.yahoo.elide.datastores.aggregation.queryengines.sql.DataSourceConfiguration; import com.yahoo.elide.datastores.aggregation.queryengines.sql.SQLQueryEngine; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; import com.yahoo.elide.datastores.aggregation.queryengines.sql.query.AggregateBeforeJoinOptimizer; +import com.yahoo.elide.datastores.aggregation.validator.TemplateConfigValidator; import com.yahoo.elide.datastores.jpa.JpaDataStore; import com.yahoo.elide.datastores.jpa.transaction.NonJtaTransaction; import com.yahoo.elide.datastores.multiplex.MultiplexManager; +import com.yahoo.elide.graphql.QueryRunners; +import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.links.DefaultJSONApiLinks; import com.yahoo.elide.modelconfig.DBPasswordExtractor; import com.yahoo.elide.modelconfig.DynamicConfiguration; +import com.yahoo.elide.modelconfig.store.ConfigDataStore; +import com.yahoo.elide.modelconfig.store.models.ConfigChecks; import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; +import com.yahoo.elide.spring.controllers.SwaggerController; import com.yahoo.elide.swagger.SwaggerBuilder; +import com.yahoo.elide.utils.HeaderUtils; import org.apache.commons.lang3.StringUtils; import org.hibernate.Session; import org.springframework.beans.factory.annotation.Autowired; @@ -52,22 +64,27 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Scope; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; import io.swagger.models.Info; -import io.swagger.models.Swagger; import lombok.extern.slf4j.Slf4j; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.TimeZone; import java.util.function.Consumer; +import java.util.function.Function; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; import javax.sql.DataSource; @@ -93,6 +110,7 @@ public class ElideAutoConfiguration { * @return An instance of DynamicConfiguration. */ @Bean + @Scope(SCOPE_PROTOTYPE) @ConditionalOnMissingBean @ConditionalOnExpression("${elide.aggregation-store.enabled:false} and ${elide.dynamic-config.enabled:false}") public DynamicConfiguration buildDynamicConfiguration(ClassScanner scanner, @@ -103,6 +121,12 @@ public DynamicConfiguration buildDynamicConfiguration(ClassScanner scanner, return validator; } + @Bean + @ConditionalOnMissingBean + public TransactionRegistry createRegistry() { + return new TransactionRegistry(); + } + /** * Creates the default Password Extractor Implementation. * @return An instance of DBPasswordExtractor. @@ -130,27 +154,35 @@ public DataSourceConfiguration getDataSourceConfiguration() { * Creates the Elide instance with standard settings. * @param dictionary Stores the static metadata about Elide models. * @param dataStore The persistence store. + * @param headerProcessor HTTP header function which is invoked for every request. + * @param transactionRegistry Global transaction registry. * @param settings Elide settings. * @return A new elide instance. */ @Bean + @RefreshScope @ConditionalOnMissingBean - public Elide initializeElide(EntityDictionary dictionary, - DataStore dataStore, - ElideConfigProperties settings, - ErrorMapper errorMapper) { + public RefreshableElide getRefreshableElide(EntityDictionary dictionary, + DataStore dataStore, + HeaderUtils.HeaderProcessor headerProcessor, + TransactionRegistry transactionRegistry, + ElideConfigProperties settings, + JsonApiMapper mapper, + ErrorMapper errorMapper) { ElideSettingsBuilder builder = new ElideSettingsBuilder(dataStore) .withEntityDictionary(dictionary) .withErrorMapper(errorMapper) + .withJsonApiMapper(mapper) .withDefaultMaxPageSize(settings.getMaxPageSize()) .withDefaultPageSize(settings.getPageSize()) - .withJoinFilterDialect(new RSQLFilterDialect(dictionary)) - .withSubqueryFilterDialect(new RSQLFilterDialect(dictionary)) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) .withAuditLogger(new Slf4jLogger()) .withBaseUrl(settings.getBaseUrl()) .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) .withJsonApiPath(settings.getJsonApi().getPath()) + .withHeaderProcessor(headerProcessor) .withGraphQLApiPath(settings.getGraphql().getPath()); if (settings.isVerboseErrors()) { @@ -163,6 +195,10 @@ public Elide initializeElide(EntityDictionary dictionary, builder.withExportApiPath(settings.getAsync().getExport().getPath()); } + if (settings.getGraphql() != null && settings.getGraphql().enableFederation) { + builder.withGraphQLFederation(true); + } + if (settings.getJsonApi() != null && settings.getJsonApi().isEnabled() && settings.getJsonApi().isEnableLinks()) { @@ -176,7 +212,37 @@ public Elide initializeElide(EntityDictionary dictionary, } } - return new Elide(builder.build()); + Elide elide = new Elide(builder.build(), transactionRegistry, dictionary.getScanner(), true); + + return new RefreshableElide(elide); + } + + + @Configuration + @ConditionalOnProperty(name = "elide.graphql.enabled", havingValue = "true") + public static class GraphQLConfiguration { + @Bean + @RefreshScope + @ConditionalOnMissingBean + public QueryRunners getQueryRunners(RefreshableElide refreshableElide) { + return new QueryRunners(refreshableElide); + } + } + + /** + * Function which preprocesses HTTP request headers before storing them in the RequestScope. + * @param settings Configuration settings + * @return A function which processes HTTP Headers. + */ + @Bean + @ConditionalOnMissingBean + public HeaderUtils.HeaderProcessor getHeaderProcessor(ElideConfigProperties settings) { + if (settings.isStripAuthorizatonHeaders()) { + return HeaderUtils::lowercaseAndRemoveAuthHeaders; + } else { + //Identity Function + return (a) -> a; + } } /** @@ -204,45 +270,63 @@ public Set> getEntitiesToExclude(ElideConfigProperties settings) { return entitiesToExclude; } + /** + * Creates the injector for dependency injection. + * @param beanFactory Injector to inject Elide models. + * @return a newly configured Injector. + */ + @Bean + @ConditionalOnMissingBean + @Scope(SCOPE_PROTOTYPE) + public Injector buildInjector(AutowireCapableBeanFactory beanFactory) { + return new Injector() { + @Override + public void inject(Object entity) { + beanFactory.autowireBean(entity); + } + + @Override + public T instantiate(Class cls) { + return beanFactory.createBean(cls); + } + }; + } + /** * Creates the entity dictionary for Elide which contains static metadata about Elide models. * Override to load check classes or life cycle hooks. - * @param beanFactory Injector to inject Elide models. + * @param injector Injector to inject Elide models. * @param dynamicConfig An instance of DynamicConfiguration. * @param settings Elide configuration settings. * @param entitiesToExclude set of Entities to exclude from binding. * @return a newly configured EntityDictionary. - * @throws ClassNotFoundException Exception thrown. */ @Bean @ConditionalOnMissingBean - public EntityDictionary buildDictionary(AutowireCapableBeanFactory beanFactory, + @Scope(SCOPE_PROTOTYPE) + public EntityDictionary buildDictionary(Injector injector, ClassScanner scanner, @Autowired(required = false) DynamicConfiguration dynamicConfig, ElideConfigProperties settings, - @Qualifier("entitiesToExclude") Set> entitiesToExclude) - throws ClassNotFoundException { + @Qualifier("entitiesToExclude") Set> entitiesToExclude) { + + Map> checks = new HashMap<>(); + + if (settings.getDynamicConfig().isConfigApiEnabled()) { + checks.put(ConfigChecks.CAN_CREATE_CONFIG, ConfigChecks.CanNotCreate.class); + checks.put(ConfigChecks.CAN_READ_CONFIG, ConfigChecks.CanNotRead.class); + checks.put(ConfigChecks.CAN_DELETE_CONFIG, ConfigChecks.CanNotDelete.class); + checks.put(ConfigChecks.CAN_UPDATE_CONFIG, ConfigChecks.CanNotUpdate.class); + } EntityDictionary dictionary = new EntityDictionary( - new HashMap<>(), //Checks + checks, //Checks new HashMap<>(), //Role Checks - new Injector() { - @Override - public void inject(Object entity) { - beanFactory.autowireBean(entity); - } - - @Override - public T instantiate(Class cls) { - return beanFactory.createBean(cls); - } - }, + injector, CoerceUtil::lookup, //Serde Lookup entitiesToExclude, scanner); - dictionary.scanForSecurityChecks(); - if (isAggregationStoreEnabled(settings) && isDynamicConfigEnabled(settings)) { dynamicConfig.getRoles().forEach(role -> { dictionary.addRoleCheck(role, new Role.RoleMemberCheck(role)); @@ -260,17 +344,17 @@ public T instantiate(Class cls) { * @param dataSourceConfiguration DataSource Configuration * @param dbPasswordExtractor Password Extractor Implementation * @return An instance of a QueryEngine - * @throws ClassNotFoundException Exception thrown. */ @Bean @ConditionalOnMissingBean @ConditionalOnProperty(name = "elide.aggregation-store.enabled", havingValue = "true") + @Scope(SCOPE_PROTOTYPE) public QueryEngine buildQueryEngine(DataSource defaultDataSource, @Autowired(required = false) DynamicConfiguration dynamicConfig, ElideConfigProperties settings, ClassScanner scanner, DataSourceConfiguration dataSourceConfiguration, - DBPasswordExtractor dbPasswordExtractor) throws ClassNotFoundException { + DBPasswordExtractor dbPasswordExtractor) { boolean enableMetaDataStore = settings.getAggregationStore().isEnableMetaDataStore(); ConnectionDetails defaultConnectionDetails = new ConnectionDetails(defaultDataSource, @@ -278,58 +362,84 @@ public QueryEngine buildQueryEngine(DataSource defaultDataSource, if (isDynamicConfigEnabled(settings)) { MetaDataStore metaDataStore = new MetaDataStore(scanner, dynamicConfig.getTables(), dynamicConfig.getNamespaceConfigurations(), enableMetaDataStore); + Map connectionDetailsMap = new HashMap<>(); dynamicConfig.getDatabaseConfigurations().forEach(dbConfig -> { connectionDetailsMap.put(dbConfig.getName(), - new ConnectionDetails( - dataSourceConfiguration.getDataSource(dbConfig, dbPasswordExtractor), - SQLDialectFactory.getDialect(dbConfig.getDialect()))); + new ConnectionDetails( + dataSourceConfiguration.getDataSource(dbConfig, dbPasswordExtractor), + SQLDialectFactory.getDialect(dbConfig.getDialect()))); }); - return new SQLQueryEngine(metaDataStore, defaultConnectionDetails, connectionDetailsMap, + Function connectionDetailsLookup = (name) -> { + if (StringUtils.isEmpty(name)) { + return defaultConnectionDetails; + } + return Optional.ofNullable(connectionDetailsMap.get(name)) + .orElseThrow(() -> new IllegalStateException("ConnectionDetails undefined for connection: " + + name)); + }; + + return new SQLQueryEngine(metaDataStore, connectionDetailsLookup, new HashSet<>(Arrays.asList(new AggregateBeforeJoinOptimizer(metaDataStore))), + new DefaultQueryPlanMerger(metaDataStore), new DefaultQueryValidator(metaDataStore.getMetadataDictionary())); } MetaDataStore metaDataStore = new MetaDataStore(scanner, enableMetaDataStore); - return new SQLQueryEngine(metaDataStore, defaultConnectionDetails); + return new SQLQueryEngine(metaDataStore, (unused) -> defaultConnectionDetails); } /** * Creates the DataStore Elide. Override to use a different store. * @param entityManagerFactory The JPA factory which creates entity managers. + * @param scanner Class Scanner * @param queryEngine QueryEngine instance for aggregation data store. * @param settings Elide configuration settings. + * @param cache Analytics query cache + * @param querylogger Analytics query logger * @return An instance of a JPA DataStore. - * @throws ClassNotFoundException Exception thrown. */ @Bean @ConditionalOnMissingBean + @Scope(SCOPE_PROTOTYPE) public DataStore buildDataStore(EntityManagerFactory entityManagerFactory, + ClassScanner scanner, @Autowired(required = false) QueryEngine queryEngine, ElideConfigProperties settings, @Autowired(required = false) Cache cache, - @Autowired(required = false) QueryLogger querylogger) - throws ClassNotFoundException { + @Autowired(required = false) QueryLogger querylogger) { + List stores = new ArrayList<>(); JpaDataStore jpaDataStore = new JpaDataStore( entityManagerFactory::createEntityManager, em -> new NonJtaTransaction(em, txCancel, DEFAULT_LOGGER, - settings.getJpaStore().isDelegateToInMemoryStore())); + settings.getJpaStore().isDelegateToInMemoryStore(), true)); + + stores.add(jpaDataStore); if (isAggregationStoreEnabled(settings)) { AggregationDataStore.AggregationDataStoreBuilder aggregationDataStoreBuilder = AggregationDataStore.builder().queryEngine(queryEngine); + if (isDynamicConfigEnabled(settings)) { aggregationDataStoreBuilder.dynamicCompiledClasses(queryEngine.getMetaDataStore().getDynamicTypes()); + + if (settings.getDynamicConfig().isConfigApiEnabled()) { + stores.add(new ConfigDataStore(settings.getDynamicConfig().getPath(), + new TemplateConfigValidator(scanner, settings.getDynamicConfig().getPath()))); + } } aggregationDataStoreBuilder.cache(cache); aggregationDataStoreBuilder.queryLogger(querylogger); AggregationDataStore aggregationDataStore = aggregationDataStoreBuilder.build(); + stores.add(queryEngine.getMetaDataStore()); + stores.add(aggregationDataStore); + // meta data store needs to be put at first to populate meta data models - return new MultiplexManager(jpaDataStore, queryEngine.getMetaDataStore(), aggregationDataStore); + return new MultiplexManager(stores.toArray(new DataStore[0])); } return jpaDataStore; @@ -369,21 +479,27 @@ public QueryLogger buildQueryLogger() { /** * Creates a singular swagger document for JSON-API. - * @param dictionary Contains the static metadata about Elide models. + * @param elide Singleton elide instance. * @param settings Elide configuration settings. * @return An instance of a JPA DataStore. */ @Bean @ConditionalOnMissingBean @ConditionalOnProperty(name = "elide.swagger.enabled", havingValue = "true") - public Swagger buildSwagger(EntityDictionary dictionary, ElideConfigProperties settings) { + @RefreshScope + public SwaggerController.SwaggerRegistrations buildSwagger( + RefreshableElide elide, + ElideConfigProperties settings + ) { + EntityDictionary dictionary = elide.getElide().getElideSettings().getDictionary(); Info info = new Info() .title(settings.getSwagger().getName()) .version(settings.getSwagger().getVersion()); SwaggerBuilder builder = new SwaggerBuilder(dictionary, info).withLegacyFilterDialect(false); - - return builder.build().basePath(settings.getJsonApi().getPath()); + return new SwaggerController.SwaggerRegistrations( + builder.build().basePath(settings.getJsonApi().getPath()) + ); } @Bean @@ -398,6 +514,13 @@ public ErrorMapper getErrorMapper() { return error -> null; } + @Bean + @ConditionalOnMissingBean + @Scope(SCOPE_PROTOTYPE) + public JsonApiMapper mapper() { + return new JsonApiMapper(); + } + private boolean isDynamicConfigEnabled(ElideConfigProperties settings) { boolean enabled = false; @@ -424,6 +547,5 @@ public static boolean isExportEnabled(AsyncProperties asyncProperties) { return asyncProperties != null && asyncProperties.getExport() != null && asyncProperties.getExport().isEnabled(); - } } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideConfigProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideConfigProperties.java index 24ec55891a..ed4a1a9085 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideConfigProperties.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideConfigProperties.java @@ -23,7 +23,7 @@ public class ElideConfigProperties { /** * Settings for the GraphQL controller. */ - private ControllerProperties graphql; + private GraphQLControllerProperties graphql; /** * Settings for the Swagger document controller. @@ -35,6 +35,11 @@ public class ElideConfigProperties { */ private AsyncProperties async = new AsyncProperties(); + /** + * Settings for subscriptions. + */ + private SubscriptionProperties subscription = new SubscriptionProperties(); + /** * Settings for the Dynamic Configuration. */ @@ -70,4 +75,9 @@ public class ElideConfigProperties { * Turns on/off verbose error responses. */ private boolean verboseErrors = false; + + /** + * Remove Authorization headers from RequestScope to prevent accidental logging of security credentials. + */ + private boolean stripAuthorizatonHeaders = true; } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionConfiguration.java new file mode 100644 index 0000000000..ece8ff9b87 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionConfiguration.java @@ -0,0 +1,72 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import static com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket.DEFAULT_USER_FACTORY; +import com.yahoo.elide.core.audit.Slf4jLogger; +import com.yahoo.elide.core.exceptions.ErrorMapper; +import com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketConfigurator; +import com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.server.standard.ServerEndpointExporter; + +import javax.jms.ConnectionFactory; +import javax.websocket.server.ServerEndpointConfig; + +/** + * Configures GraphQL subscription web sockets for Elide. + */ +@Configuration +@ConditionalOnProperty(name = "elide.graphql.enabled", havingValue = "true") +@EnableConfigurationProperties(ElideConfigProperties.class) +public class ElideSubscriptionConfiguration { + @Bean + @ConditionalOnMissingBean + @ConditionalOnExpression("${elide.subscription.enabled:false}") + ServerEndpointConfig serverEndpointConfig( + ElideConfigProperties config, + SubscriptionWebSocket.UserFactory userFactory, + ConnectionFactory connectionFactory, + ErrorMapper errorMapper + ) { + return ServerEndpointConfig.Builder + .create(SubscriptionWebSocket.class, config.getSubscription().getPath()) + .configurator(SubscriptionWebSocketConfigurator.builder() + .baseUrl(config.getSubscription().getPath()) + .sendPingOnSubscribe(config.getSubscription().isSendPingOnSubscribe()) + .connectionTimeoutMs(config.getSubscription().getConnectionTimeoutMs()) + .maxSubscriptions(config.getSubscription().maxSubscriptions) + .maxMessageSize(config.getSubscription().maxMessageSize) + .maxIdleTimeoutMs(config.getSubscription().idleTimeoutMs) + .connectionFactory(connectionFactory) + .userFactory(userFactory) + .auditLogger(new Slf4jLogger()) + .verboseErrors(config.isVerboseErrors()) + .errorMapper(errorMapper) + .build()) + .build(); + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnExpression("${elide.subscription.enabled:false}") + ServerEndpointExporter serverEndpointExporter() { + ServerEndpointExporter exporter = new ServerEndpointExporter(); + return exporter; + } + + @Bean + @ConditionalOnMissingBean + @ConditionalOnExpression("${elide.subscription.enabled:false}") + SubscriptionWebSocket.UserFactory getUserFactory() { + return DEFAULT_USER_FACTORY; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionScanningConfiguration.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionScanningConfiguration.java new file mode 100644 index 0000000000..4e1c88dad2 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ElideSubscriptionScanningConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.graphql.subscriptions.hooks.SubscriptionScanner; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; + +import javax.jms.ConnectionFactory; +import javax.jms.Message; + +/** + * Scans for GraphQL subscriptions and registers lifecycle hooks. + */ +@Configuration +@ConditionalOnExpression("${elide.subscription.enabled:false}") +public class ElideSubscriptionScanningConfiguration { + private RefreshableElide refreshableElide; + private ConnectionFactory connectionFactory; + + @Autowired + public ElideSubscriptionScanningConfiguration( + RefreshableElide refreshableElide, + ConnectionFactory connectionFactory + ) { + this.refreshableElide = refreshableElide; + this.connectionFactory = connectionFactory; + } + + @EventListener(value = { ContextRefreshedEvent.class, RefreshScopeRefreshedEvent.class }) + public void onStartOrRefresh(ApplicationEvent event) { + + Elide elide = refreshableElide.getElide(); + + SubscriptionScanner scanner = SubscriptionScanner.builder() + + //Things you may want to override... + .deliveryDelay(Message.DEFAULT_DELIVERY_DELAY) + .messagePriority(Message.DEFAULT_PRIORITY) + .timeToLive(Message.DEFAULT_TIME_TO_LIVE) + .deliveryMode(Message.DEFAULT_DELIVERY_MODE) + + //Things you probably don't care about... + .scanner(elide.getScanner()) + .dictionary(elide.getElideSettings().getDictionary()) + .connectionFactory(connectionFactory) + .build(); + + scanner.bindLifecycleHooks(); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ExportControllerProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ExportControllerProperties.java index bc15f21e07..96a61db2dd 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ExportControllerProperties.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/ExportControllerProperties.java @@ -20,6 +20,11 @@ public class ExportControllerProperties extends ControllerProperties { */ private boolean skipCSVHeader = false; + /** + * Enable Adding Extension to table export attachments. + */ + private boolean extensionEnabled = false; + /** * The URL path prefix for the controller. */ diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/GraphQLControllerProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/GraphQLControllerProperties.java new file mode 100644 index 0000000000..2126777c4c --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/GraphQLControllerProperties.java @@ -0,0 +1,22 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Extra controller properties for the GraphQL endpoint. + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class GraphQLControllerProperties extends ControllerProperties { + + /** + * Turns on/off Apollo federation schema. + */ + boolean enableFederation = false; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/SubscriptionProperties.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/SubscriptionProperties.java new file mode 100644 index 0000000000..3c0d410e30 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/config/SubscriptionProperties.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.spring.config; + +import lombok.Data; + +/** + * Extra properties for setting up GraphQL subscription support. + */ +@Data +public class SubscriptionProperties extends ControllerProperties { + + /** + * Whether Elide should publish subscription notifications to JMS on lifecycle events. + */ + protected boolean publishingEnabled = isEnabled(); + + /** + * Websocket sends a PING immediate after receiving a SUBSCRIBE. Only useful for testing. + * @see com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketTestClient + */ + protected boolean sendPingOnSubscribe = false; + + /** + * Time allowed in milliseconds from web socket creation to successfully receiving a CONNECTION_INIT message. + */ + protected int connectionTimeoutMs = 5000; + + /** + * Maximum number of outstanding GraphQL queries per websocket. + */ + protected int maxSubscriptions = 30; + + /** + * Maximum message size that can be sent to the websocket. + */ + protected int maxMessageSize = 10000; + + /** + * Maximum idle timeout in milliseconds with no websocket activity. + */ + protected long idleTimeoutMs = 300000; +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/GraphqlController.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/GraphqlController.java index 8f8c4e4c0e..99ab696796 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/GraphqlController.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/GraphqlController.java @@ -11,13 +11,17 @@ import com.yahoo.elide.core.exceptions.InvalidOperationException; import com.yahoo.elide.core.security.User; import com.yahoo.elide.graphql.QueryRunner; +import com.yahoo.elide.graphql.QueryRunners; +import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.spring.config.ElideConfigProperties; import com.yahoo.elide.spring.security.AuthenticationUser; import com.yahoo.elide.utils.HeaderUtils; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -30,7 +34,6 @@ import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import lombok.extern.slf4j.Slf4j; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -45,23 +48,27 @@ @RequestMapping(value = "${elide.graphql.path}") @EnableConfigurationProperties(ElideConfigProperties.class) @ConditionalOnExpression("${elide.graphql.enabled:false}") +@RefreshScope public class GraphqlController { private final ElideConfigProperties settings; - private final Map runners; - private final Elide elide; + private final QueryRunners runners; + private final ObjectMapper mapper; + private final HeaderUtils.HeaderProcessor headerProcessor; private static final String JSON_CONTENT_TYPE = "application/json"; @Autowired - public GraphqlController(Elide elide, ElideConfigProperties settings) { + public GraphqlController( + QueryRunners runners, + JsonApiMapper jsonApiMapper, + HeaderUtils.HeaderProcessor headerProcessor, + ElideConfigProperties settings) { log.debug("Started ~~"); + this.runners = runners; this.settings = settings; - this.elide = elide; - this.runners = new HashMap<>(); - for (String apiVersion : elide.getElideSettings().getDictionary().getApiVersions()) { - runners.put(apiVersion, new QueryRunner(elide, apiVersion)); - } + this.headerProcessor = headerProcessor; + this.mapper = jsonApiMapper.getObjectMapper(); } /** @@ -77,18 +84,19 @@ public Callable> post(@RequestHeader HttpHeaders requestH @RequestBody String graphQLDocument, Authentication principal) { final User user = new AuthenticationUser(principal); final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); - final Map> requestHeadersCleaned = - HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); - final QueryRunner runner = runners.get(apiVersion); + final Map> requestHeadersCleaned = headerProcessor.process(requestHeaders); + final QueryRunner runner = runners.getRunner(apiVersion); final String baseUrl = getBaseUrlEndpoint(); return new Callable>() { @Override public ResponseEntity call() throws Exception { ElideResponse response; + if (runner == null) { - response = buildErrorResponse(elide, new InvalidOperationException("Invalid API Version"), false); + response = buildErrorResponse(mapper, new InvalidOperationException("Invalid API Version"), false); } else { + Elide elide = runner.getElide(); response = runner.run(baseUrl, graphQLDocument, user, UUID.randomUUID(), requestHeadersCleaned); } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java index 574644444d..134d51514d 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/JsonApiController.java @@ -9,6 +9,7 @@ import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; import com.yahoo.elide.Elide; import com.yahoo.elide.ElideResponse; +import com.yahoo.elide.RefreshableElide; import com.yahoo.elide.core.security.User; import com.yahoo.elide.spring.config.ElideConfigProperties; import com.yahoo.elide.spring.security.AuthenticationUser; @@ -16,6 +17,7 @@ import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -50,18 +52,21 @@ @Configuration @RequestMapping(value = "${elide.json-api.path}") @ConditionalOnExpression("${elide.json-api.enabled:false}") +@RefreshScope public class JsonApiController { private final Elide elide; private final ElideConfigProperties settings; + private final HeaderUtils.HeaderProcessor headerProcessor; public static final String JSON_API_CONTENT_TYPE = JSONAPI_CONTENT_TYPE; public static final String JSON_API_PATCH_CONTENT_TYPE = JSONAPI_CONTENT_TYPE_WITH_JSON_PATCH_EXTENSION; @Autowired - public JsonApiController(Elide elide, ElideConfigProperties settings) { + public JsonApiController(RefreshableElide refreshableElide, ElideConfigProperties settings) { log.debug("Started ~~"); this.settings = settings; - this.elide = elide; + this.elide = refreshableElide.getElide(); + this.headerProcessor = elide.getElideSettings().getHeaderProcessor(); } private MultivaluedHashMap convert(MultiValueMap springMVMap) { @@ -75,8 +80,7 @@ public Callable> elideGet(@RequestHeader HttpHeaders requ @RequestParam MultiValueMap allRequestParams, HttpServletRequest request, Authentication authentication) { final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); - final Map> requestHeadersCleaned = - HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final Map> requestHeadersCleaned = headerProcessor.process(requestHeaders); final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); final User user = new AuthenticationUser(authentication); final String baseUrl = getBaseUrlEndpoint(); @@ -98,8 +102,7 @@ public Callable> elidePost(@RequestHeader HttpHeaders req @RequestBody String body, HttpServletRequest request, Authentication authentication) { final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); - final Map> requestHeadersCleaned = - HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final Map> requestHeadersCleaned = headerProcessor.process(requestHeaders); final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); final User user = new AuthenticationUser(authentication); final String baseUrl = getBaseUrlEndpoint(); @@ -114,14 +117,17 @@ public ResponseEntity call() throws Exception { }; } - @PatchMapping(value = "/**", consumes = { JSON_API_CONTENT_TYPE, JSON_API_PATCH_CONTENT_TYPE}) + @PatchMapping( + value = "/**", + consumes = { JSON_API_CONTENT_TYPE, JSON_API_PATCH_CONTENT_TYPE}, + produces = JSON_API_CONTENT_TYPE + ) public Callable> elidePatch(@RequestHeader HttpHeaders requestHeaders, @RequestParam MultiValueMap allRequestParams, @RequestBody String body, HttpServletRequest request, Authentication authentication) { final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); - final Map> requestHeadersCleaned = - HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final Map> requestHeadersCleaned = headerProcessor.process(requestHeaders); final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); final User user = new AuthenticationUser(authentication); final String baseUrl = getBaseUrlEndpoint(); @@ -144,8 +150,7 @@ public Callable> elideDelete(@RequestHeader HttpHeaders r HttpServletRequest request, Authentication authentication) { final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); - final Map> requestHeadersCleaned = - HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final Map> requestHeadersCleaned = headerProcessor.process(requestHeaders); final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); final User user = new AuthenticationUser(authentication); final String baseUrl = getBaseUrlEndpoint(); @@ -169,8 +174,7 @@ public Callable> elideDeleteRelation( HttpServletRequest request, Authentication authentication) { final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); - final Map> requestHeadersCleaned = - HeaderUtils.lowercaseAndRemoveAuthHeaders(requestHeaders); + final Map> requestHeadersCleaned = headerProcessor.process(requestHeaders); final String pathname = getJsonApiPath(request, settings.getJsonApi().getPath()); final User user = new AuthenticationUser(authentication); final String baseUrl = getBaseUrlEndpoint(); diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/SwaggerController.java b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/SwaggerController.java index d4c8015623..0c4b897e18 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/SwaggerController.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/java/com/yahoo/elide/spring/controllers/SwaggerController.java @@ -12,6 +12,7 @@ import org.owasp.encoder.Encode; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; @@ -37,6 +38,7 @@ * Spring REST controller for exposing Swagger documentation. */ @Slf4j +@RefreshScope @RestController @Configuration @RequestMapping(value = "${elide.swagger.path}") @@ -47,6 +49,20 @@ public class SwaggerController { protected Map, String> documents; private static final String JSON_CONTENT_TYPE = "application/json"; + /** + * Wraps a list of swagger registrations so that they can be wrapped with an AOP proxy. + */ + @Data + @AllArgsConstructor + public static class SwaggerRegistrations { + + public SwaggerRegistrations(Swagger doc) { + registrations = List.of(new SwaggerRegistration("", doc)); + } + + List registrations; + } + @Data @AllArgsConstructor public static class SwaggerRegistration { @@ -59,12 +75,12 @@ public static class SwaggerRegistration { * * @param docs A list of documents to register. */ - @Autowired(required = false) - public SwaggerController(List docs) { + @Autowired + public SwaggerController(SwaggerRegistrations docs) { log.debug("Started ~~"); documents = new HashMap<>(); - docs.forEach((doc) -> { + docs.getRegistrations().forEach((doc) -> { String apiVersion = doc.document.getInfo().getVersion(); apiVersion = apiVersion == null ? NO_VERSION : apiVersion; String apiPath = doc.path; @@ -73,16 +89,6 @@ public SwaggerController(List docs) { }); } - @Autowired(required = false) - public SwaggerController(Swagger doc) { - log.debug("Started ~~"); - documents = new HashMap<>(); - String apiVersion = doc.getInfo().getVersion(); - apiVersion = apiVersion == null ? NO_VERSION : apiVersion; - - documents.put(Pair.of(apiVersion, ""), SwaggerBuilder.getDocument(doc)); - } - @GetMapping(value = {"/", ""}, produces = JSON_CONTENT_TYPE) public Callable> list(@RequestHeader HttpHeaders requestHeaders) { final String apiVersion = HeaderUtils.resolveApiVersion(requestHeaders); diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/elide-spring/elide-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index 2d6bb5df86..2070ae5d5a 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/elide-spring/elide-spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -1,6 +1,8 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ com.yahoo.elide.spring.config.ElideAutoConfiguration, \ com.yahoo.elide.spring.config.ElideAsyncConfiguration, \ + com.yahoo.elide.spring.config.ElideSubscriptionConfiguration, \ + com.yahoo.elide.spring.config.ElideSubscriptionScanningConfiguration, \ com.yahoo.elide.spring.controllers.JsonApiController, \ com.yahoo.elide.spring.controllers.GraphqlController, \ com.yahoo.elide.spring.controllers.SwaggerController, \ diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactGroup.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactGroup.java index e5b39df5f8..69ed5b6cf3 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactGroup.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/models/jpa/ArtifactGroup.java @@ -8,6 +8,8 @@ import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.UpdatePermission; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; import example.checks.AdminCheck; import lombok.Data; @@ -20,18 +22,22 @@ @Include(name = "group") @Entity @Data +@Subscription public class ArtifactGroup { @Id private String name = ""; + @SubscriptionField private String commonName = ""; private String description = ""; @CreatePermission(expression = AdminCheck.USER_IS_ADMIN) @UpdatePermission(expression = AdminCheck.USER_IS_ADMIN) + @SubscriptionField private boolean deprecated = false; + @SubscriptionField @OneToMany(mappedBy = "group") private List products = new ArrayList<>(); } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AggregationStoreTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AggregationStoreTest.java index 7bf6461722..ddabf7bb4a 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AggregationStoreTest.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AggregationStoreTest.java @@ -13,9 +13,9 @@ import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; import static io.restassured.RestAssured.given; import static io.restassured.RestAssured.when; -import static junit.framework.TestCase.assertFalse; -import static junit.framework.TestCase.assertTrue; import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.yahoo.elide.core.exceptions.HttpStatus; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -38,6 +38,7 @@ + "\t\t(2,150,'Bar');" }) public class AggregationStoreTest extends IntegrationTest { + /** * This test demonstrates an example test using the aggregation store. */ diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java index 7e6e66aa70..d95841566c 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/AsyncTest.java @@ -161,7 +161,7 @@ public void testExportDynamicModel() throws InterruptedException { .body("data.attributes.status", equalTo("COMPLETE")) .body("data.attributes.result.message", equalTo(null)) .body("data.attributes.result.url", - equalTo("https://elide.io" + "/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d")); + equalTo("https://elide.io" + "/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d.csv")); // Validate GraphQL Response String responseGraphQL = given() @@ -176,7 +176,7 @@ public void testExportDynamicModel() throws InterruptedException { String expectedResponse = "{\"data\":{\"tableExport\":{\"edges\":[{\"node\":{\"id\":\"ba31ca4e-ed8f-4be0-a0f3-12088fa9265d\"," + "\"queryType\":\"GRAPHQL_V1_0\",\"status\":\"COMPLETE\",\"resultType\":\"CSV\"," - + "\"result\":{\"url\":\"https://elide.io/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d\",\"httpStatus\":200,\"recordCount\":1}}}]}}}"; + + "\"result\":{\"url\":\"https://elide.io/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d.csv\",\"httpStatus\":200,\"recordCount\":1}}}]}}}"; assertEquals(expectedResponse, responseGraphQL); break; @@ -184,7 +184,7 @@ public void testExportDynamicModel() throws InterruptedException { assertEquals("PROCESSING", outputResponse, "Async Query has failed."); } when() - .get("/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d") + .get("/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9265d.csv") .then() .statusCode(HttpStatus.SC_OK); } @@ -235,7 +235,7 @@ public void testExportStaticModel() throws InterruptedException { .body("data.attributes.status", equalTo("COMPLETE")) .body("data.attributes.result.message", equalTo(null)) .body("data.attributes.result.url", - equalTo("https://elide.io" + "/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d")); + equalTo("https://elide.io" + "/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d.csv")); // Validate GraphQL Response String responseGraphQL = given() @@ -250,7 +250,7 @@ public void testExportStaticModel() throws InterruptedException { String expectedResponse = "{\"data\":{\"tableExport\":{\"edges\":[{\"node\":{\"id\":\"ba31ca4e-ed8f-4be0-a0f3-12088fa9264d\"," + "\"queryType\":\"GRAPHQL_V1_0\",\"status\":\"COMPLETE\",\"resultType\":\"CSV\"," - + "\"result\":{\"url\":\"https://elide.io/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d\",\"httpStatus\":200,\"recordCount\":2}}}]}}}"; + + "\"result\":{\"url\":\"https://elide.io/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d.csv\",\"httpStatus\":200,\"recordCount\":2}}}]}}}"; assertEquals(expectedResponse, responseGraphQL); break; @@ -258,7 +258,7 @@ public void testExportStaticModel() throws InterruptedException { assertEquals("PROCESSING", outputResponse, "Async Query has failed."); } when() - .get("/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d") + .get("/export/ba31ca4e-ed8f-4be0-a0f3-12088fa9264d.csv") .then() .statusCode(HttpStatus.SC_OK); } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreIntegrationTestSetup.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreIntegrationTestSetup.java new file mode 100644 index 0000000000..fcfbb13696 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreIntegrationTestSetup.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.Injector; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.modelconfig.DynamicConfiguration; +import com.yahoo.elide.modelconfig.store.models.ConfigChecks; +import com.yahoo.elide.spring.config.ElideConfigProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +@TestConfiguration +public class ConfigStoreIntegrationTestSetup { + + @Bean + public EntityDictionary buildDictionary(AutowireCapableBeanFactory beanFactory, + ClassScanner scanner, + @Autowired(required = false) DynamicConfiguration dynamicConfig, + ElideConfigProperties settings, + @Qualifier("entitiesToExclude") Set> entitiesToExclude) { + + Map> checks = new HashMap<>(); + + if (settings.getDynamicConfig().isConfigApiEnabled()) { + checks.put(ConfigChecks.CAN_CREATE_CONFIG, ConfigChecks.CanCreate.class); + checks.put(ConfigChecks.CAN_READ_CONFIG, ConfigChecks.CanRead.class); + checks.put(ConfigChecks.CAN_DELETE_CONFIG, ConfigChecks.CanDelete.class); + checks.put(ConfigChecks.CAN_UPDATE_CONFIG, ConfigChecks.CanNotUpdate.class); + } + + EntityDictionary dictionary = new EntityDictionary( + checks, //Checks + new HashMap<>(), //Role Checks + new Injector() { + @Override + public void inject(Object entity) { + beanFactory.autowireBean(entity); + } + + @Override + public T instantiate(Class cls) { + return beanFactory.createBean(cls); + } + }, + CoerceUtil::lookup, //Serde Lookup + entitiesToExclude, + scanner); + + return dictionary; + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreTest.java new file mode 100644 index 0000000000..d0c77e4519 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ConfigStoreTest.java @@ -0,0 +1,719 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import static com.yahoo.elide.test.graphql.GraphQLDSL.argument; +import static com.yahoo.elide.test.graphql.GraphQLDSL.arguments; +import static com.yahoo.elide.test.graphql.GraphQLDSL.field; +import static com.yahoo.elide.test.graphql.GraphQLDSL.mutation; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selection; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selections; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.equalTo; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.modelconfig.store.models.ConfigFile.ConfigFileType; +import com.yahoo.elide.spring.controllers.JsonApiController; +import com.yahoo.elide.test.graphql.GraphQLDSL; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; + +import io.restassured.RestAssured; +import lombok.Builder; +import lombok.Data; + +import java.nio.file.Path; +import java.util.TimeZone; +import javax.ws.rs.core.MediaType; + +@TestInstance(TestInstance.Lifecycle.PER_METHOD) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(ConfigStoreIntegrationTestSetup.class) +@TestPropertySource( + properties = { + "elide.dynamic-config.configApiEnabled=true" + } +) +public class ConfigStoreTest { + + @Data + @Builder + public static class ConfigFile { + ConfigFileType type; + + String path; + + String content; + } + + @LocalServerPort + protected int port; + + @BeforeAll + public static void initialize(@TempDir Path testDirectory) { + System.setProperty("elide.dynamic-config.path", testDirectory.toFile().getAbsolutePath()); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @AfterAll + public static void cleanup() { + System.clearProperty("elide.dynamic-config.path"); + } + + /** + * Empty configuration load test. + */ + @Test + public void testEmptyConfiguration() { + when() + .get("http://localhost:" + port + "/json/config?fields[config]=path,type") + .then() + .body(equalTo("{\"data\":[]}")) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void testGraphQLNullContent() { + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "UPSERT"), + argument("data", "{ type: TABLE, path: \\\"models/tables/table1.hjson\\\" }") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"errors\":[{\"message\":\"Null or empty file content for models/tables/table1.hjson\"}]}")) + .statusCode(200); + } + + @Test + public void testGraphQLCreateFetchAndDelete() { + String hjson = "\\\"{\\\\n" + + " tables: [{\\\\n" + + " name: Test\\\\n" + + " table: test\\\\n" + + " schema: test\\\\n" + + " measures : [\\\\n" + + " {\\\\n" + + " name : measure\\\\n" + + " type : INTEGER\\\\n" + + " definition: 'MAX({{$measure}})'\\\\n" + + " }\\\\n" + + " ]\\\\n" + + " dimensions : [\\\\n" + + " {\\\\n" + + " name : dimension\\\\n" + + " type : TEXT\\\\n" + + " definition : '{{$dimension}}'\\\\n" + + " }\\\\n" + + " ]\\\\n" + + " }]\\\\n" + + "}\\\""; + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "UPSERT"), + argument("data", String.format("{ type: TABLE, path: \\\"models/tables/table1.hjson\\\", content: %s }", hjson)) + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo( + GraphQLDSL.document( + selection( + field( + "config", + selections( + field("id", "bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + field("path", "models/tables/table1.hjson") + ) + ) + ) + ).toResponse() + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + selection( + field("config", + argument( + argument("ids", "[\\\"bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo( + GraphQLDSL.document( + selection( + field( + "config", + selections( + field("id", "bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + field("path", "models/tables/table1.hjson") + ) + ) + ) + ).toResponse() + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "DELETE"), + argument("ids", "[\\\"bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + selection( + field("config", + selections( + field("id"), + field("path") + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + } + + @Test + public void testTwoNamespaceCreateAndDelete() { + String hjson1 = "\\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace2\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\""; + + String hjson2 = "\\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace3\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\""; + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "UPSERT"), + argument("data", String.format("[" + + "{ type: NAMESPACE, path: \\\"models/namespaces/namespace2.hjson\\\", content: %s }," + + "{ type: NAMESPACE, path: \\\"models/namespaces/namespace3.hjson\\\", content: %s }" + + "]" , hjson1, hjson2)) + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo( + GraphQLDSL.document( + selection( + field( + "config", + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMi5oanNvbg=="), + field("path", "models/namespaces/namespace2.hjson") + ), + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMy5oanNvbg=="), + field("path", "models/namespaces/namespace3.hjson") + ) + ) + ) + ).toResponse() + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "DELETE"), + argument("ids", "[\\\"bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMi5oanNvbg==\\\", \\\"bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMy5oanNvbg==\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + + } + + @Test + public void testTwoNamespaceCreationStatements() { + String query = "{ \"query\": \" mutation saveChanges {\\n one: config(op: UPSERT, data: {id:\\\"one\\\", path: \\\"models/namespaces/oneDemoNamespaces.hjson\\\", type: NAMESPACE, content: \\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace2\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\"}) {\\n edges {\\n node {\\n id\\n }\\n }\\n }\\n two: config(op: UPSERT, data: {id: \\\"two\\\", path: \\\"models/namespaces/twoDemoNamespaces.hjson\\\", type: NAMESPACE, content: \\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace3\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\"}) {\\n edges {\\n node {\\n id\\n }\\n }\\n }\\n} \" }"; + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(query) + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo( + GraphQLDSL.document( + selections( + field( + "one", + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvb25lRGVtb05hbWVzcGFjZXMuaGpzb24=") + ) + ), + field( + "two", + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvdHdvRGVtb05hbWVzcGFjZXMuaGpzb24=") + ) + ) + ) + ).toResponse().replace(" ", "") + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "DELETE"), + argument("ids", "[\\\"bW9kZWxzL25hbWVzcGFjZXMvb25lRGVtb05hbWVzcGFjZXMuaGpzb24=\\\", \\\"bW9kZWxzL25hbWVzcGFjZXMvdHdvRGVtb05hbWVzcGFjZXMuaGpzb24=\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("http://localhost:" + port + "/graphql") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + + } + + @Test + public void testJsonApiCreateFetchAndDelete() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .statusCode(HttpStatus.SC_CREATED); + + when() + .get("http://localhost:" + port + "/json/config?fields[config]=content") + .then() + .body(equalTo(data( + resource( + type("config"), + id("bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + attributes( + attr("content", hjson) + ) + ) + ).toJSON())) + .statusCode(HttpStatus.SC_OK); + + when() + .delete("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + when() + .get("http://localhost:" + port + "/json/config?fields[config]=path,type") + .then() + .body(equalTo("{\"data\":[]}")) + .statusCode(HttpStatus.SC_OK); + + when() + .get("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + } + + @Test + public void testUpdatePermissionError() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .statusCode(HttpStatus.SC_CREATED); + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + id("bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .patch("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN); + + when() + .delete("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + } + + @Test + public void testTemplateError() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}}) + {{$$column.args.missing}}'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .body(equalTo("{\"errors\":[{\"detail\":\"Failed to verify column arguments for column: measure in table: Test. Argument 'missing' is not defined but found '{{$$column.args.missing}}'.\"}]}")) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + @Test + public void testPathUpdatePermissionError() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .statusCode(HttpStatus.SC_CREATED); + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + id("bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + attributes( + attr("path", "models/tables/newName.hjson") + ) + ) + ) + ) + .when() + .patch("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN); + + when() + .delete("http://localhost:" + port + "/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + } + + @Test + public void testHackAttempt() { + String hjson = "#!/bin/sh ..."; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "foo"), + attr("type", "UNKNOWN"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .body(equalTo("{\"errors\":[{\"detail\":\"Unrecognized File: foo\"}]}")) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + @Test + public void testPathTraversalAttempt() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "../../../../../tmp/models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("http://localhost:" + port + "/json/config") + .then() + .body(equalTo("{\"errors\":[{\"detail\":\"Parent directory traversal not allowed: ../../../../../tmp/models/tables/table1.hjson\"}]}")) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java index 5d071e4db5..d854bb7423 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/ControllerTest.java @@ -31,32 +31,17 @@ import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.equalTo; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import com.yahoo.elide.Elide; import com.yahoo.elide.core.exceptions.HttpStatus; import com.yahoo.elide.spring.controllers.JsonApiController; import com.yahoo.elide.test.graphql.GraphQLDSL; -import com.google.common.collect.ImmutableList; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.boot.test.mock.mockito.SpyBean; -import org.springframework.context.annotation.Import; -import org.springframework.http.HttpHeaders; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.jdbc.Sql; import org.springframework.test.context.jdbc.SqlMergeMode; -import java.io.IOException; -import java.util.List; -import java.util.Map; import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.MultivaluedHashMap; -import javax.ws.rs.core.MultivaluedMap; /** * Example functional test. @@ -68,21 +53,16 @@ statements = "INSERT INTO ArtifactGroup (name, commonName, description, deprecated) VALUES\n" + "\t\t('com.example.repository','Example Repository','The code for this project', false);" ) -@Import(IntegrationTestSetup.class) @TestPropertySource( properties = { "elide.json-api.enableLinks=true", - "elide.async.export.enabled=false" + "elide.async.export.enabled=false", } ) @ActiveProfiles("default") public class ControllerTest extends IntegrationTest { - public static final String SORT_PARAM = "sort"; private String baseUrl; - @SpyBean - private Elide elide; - @BeforeAll @Override public void setUp() { @@ -359,7 +339,7 @@ public void graphqlTest() { } @Test - public void testInvalidApiVersion() throws IOException { + public void testInvalidApiVersion() { String graphQLRequest = GraphQLDSL.document( selection( @@ -497,166 +477,4 @@ public void exportControllerDisabledTest() { .body("error", equalTo("Not Found")) .statusCode(HttpStatus.SC_NOT_FOUND); } - - @Test - public void jsonVerifyParamsAndHeadersGetTest() { - given() - .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") - .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") - .header(HttpHeaders.ACCEPT_LANGUAGE, "en-US") - .queryParam(SORT_PARAM, "name", "description") - .contentType(JsonApiController.JSON_API_CONTENT_TYPE) - .body( - datum( - resource( - type("group"), - id("com.example.repository2"), - attributes( - attr("commonName", "New group.") - ) - ) - ) - ) - .when() - .post("/json/group") - .then() - .statusCode(HttpStatus.SC_CREATED); - - ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); - ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); - verify(elide).post(any(), any(), any(), requestParamsCaptor.capture(), requestHeadersCleanedCaptor.capture(), any(), any(), any()); - - MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); - expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); - assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); - - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); - } - - @Test - public void jsonVerifyParamsAndHeadersPostTest() { - given() - .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") - .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") - .queryParam(SORT_PARAM, "name", "description") - .when() - .get("/json/group") - .then() - .statusCode(HttpStatus.SC_OK); - - ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); - ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); - verify(elide).get(any(), any(), requestParamsCaptor.capture(), requestHeadersCleanedCaptor.capture(), any(), any(), any()); - - MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); - expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); - assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); - - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); - } - - @Test - public void jsonVerifyParamsAndHeadersPatchTest() { - given() - .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") - .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") - .queryParam(SORT_PARAM, "name", "description") - .contentType(JsonApiController.JSON_API_CONTENT_TYPE) - .body( - datum( - resource( - type("group"), - id("com.example.repository"), - attributes( - attr("commonName", "Changed It.") - ) - ) - ) - ) - .when() - .patch("/json/group/com.example.repository") - .then() - .statusCode(HttpStatus.SC_NO_CONTENT); - - ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); - ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); - verify(elide).patch(any(), any(), any(), any(), any(), requestParamsCaptor.capture(), requestHeadersCleanedCaptor.capture(), any(), any(), any()); - - MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); - expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); - assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); - - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); - } - - @Test - public void jsonVerifyParamsAndHeadersDeleteTest() { - given() - .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") - .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") - .queryParam(SORT_PARAM, "name", "description") - .when() - .delete("/json/group/com.example.repository") - .then() - .statusCode(HttpStatus.SC_NO_CONTENT); - - ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); - ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); - verify(elide).delete( - any(), - any(), - any(), - requestParamsCaptor.capture(), - requestHeadersCleanedCaptor.capture(), - any(), - any(), - any() - ); - - MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); - expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); - assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); - - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); - } - - @Test - public void jsonVerifyParamsAndHeadersDeleteRelationshipTest() { - given() - .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") - .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") - .queryParam(SORT_PARAM, "name", "description") - .contentType(JsonApiController.JSON_API_CONTENT_TYPE) - .body(datum( - linkage(type("product"), id("foo")) - )) - .when() - .delete("/json/group/com.example.repository") - .then() - .statusCode(HttpStatus.SC_NO_CONTENT); - - ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); - ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); - verify(elide).delete( - any(), - any(), - any(), - requestParamsCaptor.capture(), - requestHeadersCleanedCaptor.capture(), - any(), - any(), - any() - ); - - MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); - expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); - assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); - - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); - assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); - } } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/HeaderCleansingTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/HeaderCleansingTest.java new file mode 100644 index 0000000000..169d4c9308 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/HeaderCleansingTest.java @@ -0,0 +1,245 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.linkage; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.spring.controllers.JsonApiController; +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlMergeMode; + +import java.util.List; +import java.util.Map; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Example functional test. + */ +@SqlMergeMode(SqlMergeMode.MergeMode.MERGE) +@Sql( + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + scripts = "classpath:db/test_init.sql", + statements = "INSERT INTO ArtifactGroup (name, commonName, description, deprecated) VALUES\n" + + "\t\t('com.example.repository','Example Repository','The code for this project', false);" +) +@Import(IntegrationTestSetup.class) +@TestPropertySource( + properties = { + "elide.json-api.enableLinks=true", + "elide.async.export.enabled=false", + } +) +@ActiveProfiles("default") +public class HeaderCleansingTest extends IntegrationTest { + public static final String SORT_PARAM = "sort"; + private String baseUrl; + + @SpyBean + private RefreshableElide elide; + + @Autowired + private ApplicationContext applicationContext; + + @BeforeAll + @Override + public void setUp() { + super.setUp(); + baseUrl = "https://elide.io/json/"; + } + + @BeforeEach + public void resetMocks() { + reset(elide.getElide()); + } + + @Test + public void jsonVerifyParamsAndHeadersGetTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en-US") + .queryParam(SORT_PARAM, "name", "description") + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("group"), + id("com.example.repository2"), + attributes( + attr("commonName", "New group.") + ) + ) + ) + ) + .when() + .post("/json/group") + .then() + .statusCode(HttpStatus.SC_CREATED); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).post(any(), any(), any(), requestParamsCaptor.capture(), requestHeadersCleanedCaptor.capture(), any(), any(), any()); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } + + @Test + public void jsonVerifyParamsAndHeadersPostTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .queryParam(SORT_PARAM, "name", "description") + .when() + .get("/json/group") + .then() + .statusCode(HttpStatus.SC_OK); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).get(any(), any(), requestParamsCaptor.capture(), requestHeadersCleanedCaptor.capture(), any(), any(), any()); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } + + @Test + public void jsonVerifyParamsAndHeadersPatchTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .queryParam(SORT_PARAM, "name", "description") + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("group"), + id("com.example.repository"), + attributes( + attr("commonName", "Changed It.") + ) + ) + ) + ) + .when() + .patch("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).patch(any(), any(), any(), any(), any(), requestParamsCaptor.capture(), requestHeadersCleanedCaptor.capture(), any(), any(), any()); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } + + @Test + public void jsonVerifyParamsAndHeadersDeleteTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .queryParam(SORT_PARAM, "name", "description") + .when() + .delete("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).delete( + any(), + any(), + any(), + requestParamsCaptor.capture(), + requestHeadersCleanedCaptor.capture(), + any(), + any(), + any() + ); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } + + @Test + public void jsonVerifyParamsAndHeadersDeleteRelationshipTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .queryParam(SORT_PARAM, "name", "description") + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body(datum( + linkage(type("product"), id("foo")) + )) + .when() + .delete("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).delete( + any(), + any(), + any(), + requestParamsCaptor.capture(), + requestHeadersCleanedCaptor.capture(), + any(), + any(), + any() + ); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertFalse(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/HeaderIdentityTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/HeaderIdentityTest.java new file mode 100644 index 0000000000..8162c4fc27 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/HeaderIdentityTest.java @@ -0,0 +1,246 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.linkage; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.spring.controllers.JsonApiController; +import com.google.common.collect.ImmutableList; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlMergeMode; + +import java.util.List; +import java.util.Map; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.MultivaluedMap; + +/** + * Example functional test. + */ +@SqlMergeMode(SqlMergeMode.MergeMode.MERGE) +@Sql( + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + scripts = "classpath:db/test_init.sql", + statements = "INSERT INTO ArtifactGroup (name, commonName, description, deprecated) VALUES\n" + + "\t\t('com.example.repository','Example Repository','The code for this project', false);" +) +@Import(IntegrationTestSetup.class) +@TestPropertySource( + properties = { + "elide.json-api.enableLinks=true", + "elide.async.export.enabled=false", + "elide.stripAuthorizatonHeaders=false" + } +) +@ActiveProfiles("default") +public class HeaderIdentityTest extends IntegrationTest { + public static final String SORT_PARAM = "sort"; + private String baseUrl; + + @SpyBean + private RefreshableElide elide; + + @Autowired + private ApplicationContext applicationContext; + + @BeforeAll + @Override + public void setUp() { + super.setUp(); + baseUrl = "https://elide.io/json/"; + } + + @BeforeEach + public void resetMocks() { + reset(elide.getElide()); + } + + @Test + public void jsonVerifyParamsAndHeadersGetTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.ACCEPT_LANGUAGE, "en-US") + .queryParam(SORT_PARAM, "name", "description") + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("group"), + id("com.example.repository2"), + attributes( + attr("commonName", "New group.") + ) + ) + ) + ) + .when() + .post("/json/group") + .then() + .statusCode(HttpStatus.SC_CREATED); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).post(any(), any(), any(), requestParamsCaptor.capture(), requestHeadersCleanedCaptor.capture(), any(), any(), any()); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } + + @Test + public void jsonVerifyParamsAndHeadersPostTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .queryParam(SORT_PARAM, "name", "description") + .when() + .get("/json/group") + .then() + .statusCode(HttpStatus.SC_OK); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).get(any(), any(), requestParamsCaptor.capture(), requestHeadersCleanedCaptor.capture(), any(), any(), any()); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } + + @Test + public void jsonVerifyParamsAndHeadersPatchTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .queryParam(SORT_PARAM, "name", "description") + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("group"), + id("com.example.repository"), + attributes( + attr("commonName", "Changed It.") + ) + ) + ) + ) + .when() + .patch("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).patch(any(), any(), any(), any(), any(), requestParamsCaptor.capture(), requestHeadersCleanedCaptor.capture(), any(), any(), any()); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } + + @Test + public void jsonVerifyParamsAndHeadersDeleteTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .queryParam(SORT_PARAM, "name", "description") + .when() + .delete("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).delete( + any(), + any(), + any(), + requestParamsCaptor.capture(), + requestHeadersCleanedCaptor.capture(), + any(), + any(), + any() + ); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } + + @Test + public void jsonVerifyParamsAndHeadersDeleteRelationshipTest() { + given() + .header(HttpHeaders.AUTHORIZATION, "willBeRemoved") + .header(HttpHeaders.PROXY_AUTHORIZATION, "willBeRemoved") + .queryParam(SORT_PARAM, "name", "description") + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body(datum( + linkage(type("product"), id("foo")) + )) + .when() + .delete("/json/group/com.example.repository") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + ArgumentCaptor> requestParamsCaptor = ArgumentCaptor.forClass(MultivaluedMap.class); + ArgumentCaptor>> requestHeadersCleanedCaptor = ArgumentCaptor.forClass(Map.class); + verify(elide.getElide()).delete( + any(), + any(), + any(), + requestParamsCaptor.capture(), + requestHeadersCleanedCaptor.capture(), + any(), + any(), + any() + ); + + MultivaluedHashMap expectedRequestParams = new MultivaluedHashMap<>(); + expectedRequestParams.put(SORT_PARAM, ImmutableList.of("name", "description")); + assertEquals(expectedRequestParams, requestParamsCaptor.getValue()); + + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("authorization")); + assertTrue(requestHeadersCleanedCaptor.getValue().containsKey("proxy-authorization")); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/IntegrationTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/IntegrationTest.java index 13a2c741d6..3fc54fbf50 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/IntegrationTest.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/IntegrationTest.java @@ -5,6 +5,8 @@ */ package example.tests; +import static io.restassured.RestAssured.given; +import com.yahoo.elide.core.exceptions.HttpStatus; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.TestInstance; import org.springframework.boot.test.context.SpringBootTest; @@ -30,4 +32,14 @@ public void setUp() { RestAssured.port = port; RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); } + + public void refreshServer() { + given() + .contentType("application/json") + .when() + .post("/actuator/refresh") + .then() + .log().all() + .statusCode(HttpStatus.SC_OK); + } } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/IntegrationTestSetup.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/IntegrationTestSetup.java index 79c022cbcb..830bb77c97 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/IntegrationTestSetup.java +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/IntegrationTestSetup.java @@ -5,9 +5,79 @@ */ package example.tests; +import static org.mockito.Mockito.spy; +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.core.TransactionRegistry; +import com.yahoo.elide.core.audit.Slf4jLogger; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.ErrorMapper; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.jsonapi.links.DefaultJSONApiLinks; +import com.yahoo.elide.spring.config.ElideConfigProperties; +import com.yahoo.elide.utils.HeaderUtils; +import org.apache.commons.lang3.StringUtils; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import java.util.TimeZone; @TestConfiguration public class IntegrationTestSetup { //Initialize beans here if needed. + + //We recreate the Elide bean here without @RefreshScope so that it can be used With @SpyBean. + @Bean + public RefreshableElide initializeElide(EntityDictionary dictionary, + DataStore dataStore, + ElideConfigProperties settings, + HeaderUtils.HeaderProcessor headerProcessor, + JsonApiMapper mapper, + ErrorMapper errorMapper) { + + ElideSettingsBuilder builder = new ElideSettingsBuilder(dataStore) + .withEntityDictionary(dictionary) + .withErrorMapper(errorMapper) + .withJsonApiMapper(mapper) + .withDefaultMaxPageSize(settings.getMaxPageSize()) + .withDefaultPageSize(settings.getPageSize()) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withAuditLogger(new Slf4jLogger()) + .withBaseUrl(settings.getBaseUrl()) + .withHeaderProcessor(headerProcessor) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .withJsonApiPath(settings.getJsonApi().getPath()) + .withGraphQLApiPath(settings.getGraphql().getPath()); + + if (settings.isVerboseErrors()) { + builder.withVerboseErrors(); + } + + if (settings.getAsync() != null + && settings.getAsync().getExport() != null + && settings.getAsync().getExport().isEnabled()) { + builder.withExportApiPath(settings.getAsync().getExport().getPath()); + } + + if (settings.getJsonApi() != null + && settings.getJsonApi().isEnabled() + && settings.getJsonApi().isEnableLinks()) { + String baseUrl = settings.getBaseUrl(); + + if (StringUtils.isEmpty(baseUrl)) { + builder.withJSONApiLinks(new DefaultJSONApiLinks()); + } else { + String jsonApiBaseUrl = baseUrl + settings.getJsonApi().getPath() + "/"; + builder.withJSONApiLinks(new DefaultJSONApiLinks(jsonApiBaseUrl)); + } + } + + Elide elide = new Elide(builder.build(), new TransactionRegistry(), dictionary.getScanner(), true); + + return new RefreshableElide(spy(elide)); + } } diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/SubscriptionTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/SubscriptionTest.java new file mode 100644 index 0000000000..e470bd06d7 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/SubscriptionTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.tests; + +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketTestClient; +import com.yahoo.elide.spring.controllers.JsonApiController; +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.Test; +import graphql.ExecutionResult; + +import java.net.URI; +import java.util.List; +import javax.websocket.ContainerProvider; +import javax.websocket.Session; +import javax.websocket.WebSocketContainer; + +public class SubscriptionTest extends IntegrationTest { + + @Test + public void testSubscription() throws Exception { + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + SubscriptionWebSocketTestClient client = new SubscriptionWebSocketTestClient(1, + List.of("subscription {group(topic: ADDED) {name commonName}}")); + + try (Session session = container.connectToServer(client, new URI("ws://localhost:" + port + "/subscription"))) { + + //Wait for the socket to be full established. + client.waitOnSubscribe(10); + + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + datum( + resource( + type("group"), + id("com.example.repository2"), + attributes( + attr("commonName", "New group.") + ) + ) + ) + ) + .when() + .post("/json/group") + .then() + .statusCode(HttpStatus.SC_CREATED); + + + List results = client.waitOnClose(10); + + assertEquals(1, results.size()); + assertEquals(0, results.get(0).getErrors().size()); + } + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/Update200StatusTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/Update200StatusTest.java new file mode 100644 index 0000000000..58a299737b --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/Update200StatusTest.java @@ -0,0 +1,61 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import static io.restassured.RestAssured.given; + +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.spring.controllers.JsonApiController; +import com.yahoo.elide.test.jsonapi.JsonApiDSL; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlMergeMode; + +/** + * Verifies 200 Status for patch Requests. + */ +@Import(Update200StatusTestSetup.class) +@SqlMergeMode(SqlMergeMode.MergeMode.MERGE) +@Sql( + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + scripts = "classpath:db/test_init.sql", + statements = "INSERT INTO ArtifactGroup (name, commonName, description, deprecated) VALUES\n" + + "\t\t('com.example.repository','Example Repository','The code for this project', false);" +) +public class Update200StatusTest extends IntegrationTest { + private String baseUrl; + + @BeforeAll + @Override + public void setUp() { + super.setUp(); + baseUrl = "https://elide.io/json/"; + } + + @Test + public void jsonApiPatchTest() { + given() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .body( + JsonApiDSL.datum( + JsonApiDSL.resource( + JsonApiDSL.type("group"), + JsonApiDSL.id("com.example.repository"), + JsonApiDSL.attributes( + JsonApiDSL.attr("commonName", "Changed It.") + ) + ) + ) + ) + .when() + .patch("/json/group/com.example.repository") + .then() + .contentType(JsonApiController.JSON_API_CONTENT_TYPE) + .statusCode(HttpStatus.SC_OK); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/Update200StatusTestSetup.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/Update200StatusTestSetup.java new file mode 100644 index 0000000000..bd4fccdb24 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/Update200StatusTestSetup.java @@ -0,0 +1,56 @@ +/* + * Copyright 2022, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example.tests; + +import com.yahoo.elide.Elide; +import com.yahoo.elide.ElideSettingsBuilder; +import com.yahoo.elide.RefreshableElide; +import com.yahoo.elide.core.TransactionRegistry; +import com.yahoo.elide.core.audit.Slf4jLogger; +import com.yahoo.elide.core.datastore.DataStore; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.exceptions.ErrorMapper; +import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; +import com.yahoo.elide.jsonapi.JsonApiMapper; +import com.yahoo.elide.spring.config.ElideConfigProperties; +import com.yahoo.elide.utils.HeaderUtils; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import java.util.TimeZone; + +@TestConfiguration +public class Update200StatusTestSetup { + @Bean + public RefreshableElide getRefreshableElide(EntityDictionary dictionary, + DataStore dataStore, + HeaderUtils.HeaderProcessor headerProcessor, + TransactionRegistry transactionRegistry, + ElideConfigProperties settings, + JsonApiMapper mapper, + ErrorMapper errorMapper) { + + ElideSettingsBuilder builder = new ElideSettingsBuilder(dataStore) + .withEntityDictionary(dictionary) + .withErrorMapper(errorMapper) + .withJsonApiMapper(mapper) + .withDefaultMaxPageSize(settings.getMaxPageSize()) + .withDefaultPageSize(settings.getPageSize()) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withAuditLogger(new Slf4jLogger()) + .withBaseUrl(settings.getBaseUrl()) + .withISO8601Dates("yyyy-MM-dd'T'HH:mm'Z'", TimeZone.getTimeZone("UTC")) + .withJsonApiPath(settings.getJsonApi().getPath()) + .withHeaderProcessor(headerProcessor) + .withGraphQLApiPath(settings.getGraphql().getPath()) + .withUpdate200Status(); + + Elide elide = new Elide(builder.build(), transactionRegistry, dictionary.getScanner(), true); + + return new RefreshableElide(elide); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/hotreload/HotReloadAggregationStoreTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/hotreload/HotReloadAggregationStoreTest.java new file mode 100644 index 0000000000..bc00e6afdf --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/hotreload/HotReloadAggregationStoreTest.java @@ -0,0 +1,26 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.tests.hotreload; + +import example.tests.AggregationStoreTest; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.test.context.TestPropertySource; + +@TestPropertySource( + properties = { + "management.endpoints.web.exposure.include: *" + } +) +public class HotReloadAggregationStoreTest extends AggregationStoreTest { + + @Override + @BeforeAll + public void setUp() { + super.setUp(); + refreshServer(); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/hotreload/HotReloadControllerTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/hotreload/HotReloadControllerTest.java new file mode 100644 index 0000000000..f8296763e5 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/hotreload/HotReloadControllerTest.java @@ -0,0 +1,26 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.tests.hotreload; + +import example.tests.ControllerTest; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.test.context.TestPropertySource; + +@TestPropertySource( + properties = { + "management.endpoints.web.exposure.include: *" + } +) +public class HotReloadControllerTest extends ControllerTest { + + @Override + @BeforeAll + public void setUp() { + super.setUp(); + refreshServer(); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/hotreload/HotReloadSubscriptionTest.java b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/hotreload/HotReloadSubscriptionTest.java new file mode 100644 index 0000000000..a853be5026 --- /dev/null +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/java/example/tests/hotreload/HotReloadSubscriptionTest.java @@ -0,0 +1,26 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example.tests.hotreload; + +import example.tests.SubscriptionTest; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.test.context.TestPropertySource; + +@TestPropertySource( + properties = { + "management.endpoints.web.exposure.include: *" + } +) +public class HotReloadSubscriptionTest extends SubscriptionTest { + + @Override + @BeforeAll + public void setUp() { + super.setUp(); + refreshServer(); + } +} diff --git a/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/application.yaml b/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/application.yaml index 6eead13406..3df45b0160 100644 --- a/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/application.yaml +++ b/elide-spring/elide-spring-boot-autoconfigure/src/test/resources/application.yaml @@ -12,6 +12,10 @@ elide: swagger: path: /doc enabled: true + subscription: + enabled: true + sendPingOnSubscribe: true + path: /subscription async: enabled: true threadPoolSize: 7 @@ -22,9 +26,11 @@ elide: export: enabled: true path: /export + extensionEnabled: true dynamic-config: path: src/test/resources/configs enabled: true + configApiEnabled: false aggregation-store: enabled: true default-dialect: h2 @@ -46,3 +52,6 @@ spring: username: 'sa' password: '' driver-class-name: 'org.h2.Driver' + activemq: + broker-url: 'vm://embedded?broker.persistent=false,useShutdownHook=false' + in-memory: true diff --git a/elide-spring/elide-spring-boot-starter/pom.xml b/elide-spring/elide-spring-boot-starter/pom.xml index b291cad7fb..937cb5911c 100644 --- a/elide-spring/elide-spring-boot-starter/pom.xml +++ b/elide-spring/elide-spring-boot-starter/pom.xml @@ -8,7 +8,7 @@ com.yahoo.elide elide-spring-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -44,43 +44,49 @@ com.yahoo.elide elide-core - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-graphql - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-datastore-jpa - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-datastore-aggregation - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT + + + + com.yahoo.elide + elide-datastore-jms + 6.1.10-SNAPSHOT com.yahoo.elide elide-swagger - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-async - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-spring-boot-autoconfigure - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -88,10 +94,15 @@ spring-boot-starter-web + + org.springframework.cloud + spring-cloud-context + + io.micrometer micrometer-core - 1.7.3 + 1.9.5 @@ -99,10 +110,16 @@ spring-boot-starter-data-jpa + + org.springframework + spring-websocket + + org.yaml snakeyaml + 1.33 diff --git a/elide-spring/pom.xml b/elide-spring/pom.xml index 44865e7983..1876e1af51 100644 --- a/elide-spring/pom.xml +++ b/elide-spring/pom.xml @@ -14,7 +14,7 @@ elide-parent-pom com.yahoo.elide - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -40,8 +40,9 @@ ${project.basedir}/../.. - 2.5.4 - 3.0.8 + 2.7.5 + 3.0.13 + 9.0.58 @@ -51,6 +52,35 @@ + + org.apache.tomcat.embed + tomcat-embed-core + + ${tomcat.version} + true + + + org.apache.tomcat.embed + + tomcat-embed-websocket + ${tomcat.version} + true + + + org.springframework + spring-websocket + 5.3.23 + + + org.springframework.cloud + spring-cloud-context + 3.1.4 + + + org.springframework.boot + spring-boot-starter-actuator + ${spring.boot.version} + io.rest-assured rest-assured @@ -88,6 +118,16 @@ pom import + + org.apache.logging.log4j + log4j-to-slf4j + 2.19.0 + + + org.apache.logging.log4j + log4j-api + 2.19.0 + diff --git a/elide-standalone/pom.xml b/elide-standalone/pom.xml index dbf2b91bea..1fccfc5e35 100644 --- a/elide-standalone/pom.xml +++ b/elide-standalone/pom.xml @@ -1,5 +1,5 @@ @@ -14,7 +14,7 @@ com.yahoo.elide elide-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -45,8 +45,7 @@ - 1.2.3 - 4.2.3 + 4.2.12 utf-8 @@ -57,34 +56,39 @@ com.yahoo.elide elide-datastore-aggregation - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-datastore-jpa - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT + + + com.yahoo.elide + elide-datastore-jms + 6.1.10-SNAPSHOT com.yahoo.elide elide-graphql - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-swagger - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-async - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT com.yahoo.elide elide-model-config - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -111,6 +115,13 @@ org.eclipse.jetty jetty-server compile + + + + org.eclipse.jetty.toolchain + jetty-servlet-api + + org.eclipse.jetty @@ -144,7 +155,6 @@ ch.qos.logback logback-core - ${logback.version} compile @@ -174,6 +184,40 @@ compile + + + org.eclipse.jetty.websocket + websocket-jetty-server + ${version.jetty} + + + + org.eclipse.jetty.toolchain + jetty-servlet-api + + + + + + org.eclipse.jetty.websocket + websocket-servlet + ${version.jetty} + + + + org.eclipse.jetty.websocket + websocket-javax-server + ${version.jetty} + + + + org.eclipse.jetty.toolchain + jetty-javax-websocket-api + + + + + org.junit.jupiter @@ -197,7 +241,7 @@ com.yahoo.elide elide-test-helpers - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT test @@ -205,7 +249,6 @@ com.h2database h2 - 1.4.200 test @@ -215,6 +258,28 @@ test + + org.apache.activemq + artemis-server + 2.26.0 + test + + + + org.apache.activemq + artemis-jms-client-all + 2.26.0 + test + + + + org.eclipse.jetty.websocket + websocket-jetty-client + ${version.jetty} + test + + + com.fasterxml.jackson.core jackson-annotations @@ -227,7 +292,7 @@ org.apache.maven.plugins maven-jar-plugin - 3.2.0 + 3.3.0 org.apache.maven.plugins diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/ElideStandalone.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/ElideStandalone.java index 37112d62b0..82d263ddd0 100644 --- a/elide-standalone/src/main/java/com/yahoo/elide/standalone/ElideStandalone.java +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/ElideStandalone.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -12,6 +12,7 @@ import com.yahoo.elide.core.security.checks.Check; import com.yahoo.elide.standalone.config.ElideResourceConfig; import com.yahoo.elide.standalone.config.ElideStandaloneSettings; +import com.yahoo.elide.standalone.config.ElideStandaloneSubscriptionSettings; import com.codahale.metrics.servlet.InstrumentedFilter; import com.codahale.metrics.servlets.AdminServlet; import com.codahale.metrics.servlets.HealthCheckServlet; @@ -20,6 +21,7 @@ import org.eclipse.jetty.servlet.FilterHolder; import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.servlet.ServletHolder; +import org.eclipse.jetty.websocket.javax.server.config.JavaxWebSocketServletContainerInitializer; import org.glassfish.jersey.servlet.ServletContainer; import lombok.extern.slf4j.Slf4j; @@ -108,6 +110,14 @@ public void start(boolean block) throws Exception { jerseyServlet.setInitParameter("jersey.config.server.provider.packages", "com.yahoo.elide.graphql"); jerseyServlet.setInitParameter("javax.ws.rs.Application", ElideResourceConfig.class.getCanonicalName()); } + ElideStandaloneSubscriptionSettings subscriptionSettings = elideStandaloneSettings.getSubscriptionProperties(); + if (elideStandaloneSettings.enableGraphQL() && subscriptionSettings.enabled()) { + // GraphQL subscription endpoint + JavaxWebSocketServletContainerInitializer.configure(context, (servletContext, serverContainer) -> { + serverContainer.addEndpoint(subscriptionSettings.serverEndpointConfig(elideStandaloneSettings)); + }); + + } if (elideStandaloneSettings.getAsyncProperties().enableExport()) { ServletHolder jerseyServlet = context.addServlet(ServletContainer.class, diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/Util.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/Util.java index 8f7fa84000..5bce3beb56 100644 --- a/elide-standalone/src/main/java/com/yahoo/elide/standalone/Util.java +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/Util.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideResourceConfig.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideResourceConfig.java index e6c306f83e..8cca8d92e7 100644 --- a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideResourceConfig.java +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideResourceConfig.java @@ -1,13 +1,13 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ package com.yahoo.elide.standalone.config; import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.CREATE; -import static com.yahoo.elide.annotation.LifeCycleHookBinding.Operation.READ; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.POSTCOMMIT; +import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PREFLUSH; import static com.yahoo.elide.annotation.LifeCycleHookBinding.TransactionPhase.PRESECURITY; import com.yahoo.elide.Elide; import com.yahoo.elide.ElideSettings; @@ -27,6 +27,7 @@ import com.yahoo.elide.async.service.dao.DefaultAsyncAPIDAO; import com.yahoo.elide.async.service.storageengine.FileResultStorageEngine; import com.yahoo.elide.async.service.storageengine.ResultStorageEngine; +import com.yahoo.elide.core.TransactionRegistry; import com.yahoo.elide.core.datastore.DataStore; import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.exceptions.InvalidOperationException; @@ -46,6 +47,7 @@ import org.glassfish.hk2.api.TypeLiteral; import org.glassfish.jersey.internal.inject.AbstractBinder; import org.glassfish.jersey.server.ResourceConfig; +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import java.io.IOException; @@ -77,6 +79,131 @@ public class ElideResourceConfig extends ResourceConfig { private static MetricRegistry metricRegistry = null; private static HealthCheckRegistry healthCheckRegistry = null; + @AllArgsConstructor + public class ElideBinder extends AbstractBinder { + + private ClassScanner classScanner; + private Optional dynamicConfiguration; + private ServletContext servletContext; + + @Override + protected void configure() { + ElideStandaloneAsyncSettings asyncProperties = settings.getAsyncProperties() == null + ? new ElideStandaloneAsyncSettings() { } : settings.getAsyncProperties(); + EntityManagerFactory entityManagerFactory = Util.getEntityManagerFactory(classScanner, + settings.getModelPackageName(), asyncProperties.enabled(), settings.getDatabaseProperties()); + + + EntityDictionary dictionary = settings.getEntityDictionary(injector, classScanner, dynamicConfiguration, + settings.getEntitiesToExclude()); + + DataStore dataStore; + + if (settings.getAnalyticProperties().enableAggregationDataStore()) { + MetaDataStore metaDataStore = settings.getMetaDataStore(classScanner, dynamicConfiguration); + if (metaDataStore == null) { + throw new IllegalStateException("Aggregation Datastore is enabled but metaDataStore is null"); + } + + DataSource defaultDataSource = Util.getDataSource(settings.getDatabaseProperties()); + ConnectionDetails defaultConnectionDetails = new ConnectionDetails(defaultDataSource, + SQLDialectFactory.getDialect(settings.getAnalyticProperties().getDefaultDialect())); + + QueryEngine queryEngine = settings.getQueryEngine(metaDataStore, defaultConnectionDetails, + dynamicConfiguration, settings.getDataSourceConfiguration(), + settings.getAnalyticProperties().getDBPasswordExtractor()); + AggregationDataStore aggregationDataStore = settings.getAggregationDataStore(queryEngine); + if (aggregationDataStore == null) { + throw new IllegalStateException( + "Aggregation Datastore is enabled but aggregationDataStore is null"); + } + dataStore = settings.getDataStore(metaDataStore, aggregationDataStore, entityManagerFactory); + + } else { + dataStore = settings.getDataStore(entityManagerFactory); + } + + ElideSettings elideSettings = settings.getElideSettings(dictionary, dataStore, + settings.getObjectMapper()); + Elide elide = new Elide(elideSettings, new TransactionRegistry(), + elideSettings.getDictionary().getScanner(), false); + + // Bind elide instance for injection into endpoint + bind(elide).to(Elide.class).named("elide"); + + // Bind additional elements + bind(elideSettings).to(ElideSettings.class); + bind(elideSettings.getDictionary()).to(EntityDictionary.class); + bind(elideSettings.getDataStore()).to(DataStore.class).named("elideDataStore"); + + // Binding async service + if (asyncProperties.enabled()) { + bindAsync(asyncProperties, elide, dictionary); + } + } + + protected void bindAsync( + ElideStandaloneAsyncSettings asyncProperties, + Elide elide, + EntityDictionary dictionary + ) { + AsyncAPIDAO asyncAPIDao = asyncProperties.getAPIDAO(); + if (asyncAPIDao == null) { + asyncAPIDao = new DefaultAsyncAPIDAO(elide.getElideSettings(), elide.getDataStore()); + } + bind(asyncAPIDao).to(AsyncAPIDAO.class); + + ExecutorService executor = (ExecutorService) servletContext.getAttribute(ASYNC_EXECUTOR_ATTR); + ExecutorService updater = (ExecutorService) servletContext.getAttribute(ASYNC_UPDATER_ATTR); + AsyncExecutorService asyncExecutorService = + new AsyncExecutorService(elide, executor, updater, asyncAPIDao); + bind(asyncExecutorService).to(AsyncExecutorService.class); + + if (asyncProperties.enableExport()) { + ExportApiProperties exportApiProperties = new ExportApiProperties( + asyncProperties.getExportAsyncResponseExecutor(), + asyncProperties.getExportAsyncResponseTimeoutSeconds()); + bind(exportApiProperties).to(ExportApiProperties.class).named("exportApiProperties"); + + ResultStorageEngine resultStorageEngine = asyncProperties.getResultStorageEngine(); + if (resultStorageEngine == null) { + resultStorageEngine = new FileResultStorageEngine(asyncProperties.getStorageDestination(), + asyncProperties.enableExtension()); + } + bind(resultStorageEngine).to(ResultStorageEngine.class).named("resultStorageEngine"); + + // Initialize the Formatters. + Map supportedFormatters = new HashMap<>(); + supportedFormatters.put(ResultType.CSV, new CSVExportFormatter(elide, + asyncProperties.skipCSVHeader())); + supportedFormatters.put(ResultType.JSON, new JSONExportFormatter(elide)); + + // Binding TableExport LifeCycleHook + TableExportHook tableExportHook = getTableExportHook(asyncExecutorService, + asyncProperties, supportedFormatters, resultStorageEngine); + dictionary.bindTrigger(TableExport.class, CREATE, PREFLUSH, tableExportHook, false); + dictionary.bindTrigger(TableExport.class, CREATE, POSTCOMMIT, tableExportHook, false); + dictionary.bindTrigger(TableExport.class, CREATE, PRESECURITY, tableExportHook, false); + } + + // Binding AsyncQuery LifeCycleHook + AsyncQueryHook asyncQueryHook = new AsyncQueryHook(asyncExecutorService, + asyncProperties.getMaxAsyncAfterSeconds()); + + dictionary.bindTrigger(AsyncQuery.class, CREATE, PREFLUSH, asyncQueryHook, false); + dictionary.bindTrigger(AsyncQuery.class, CREATE, POSTCOMMIT, asyncQueryHook, false); + dictionary.bindTrigger(AsyncQuery.class, CREATE, PRESECURITY, asyncQueryHook, false); + + // Binding async cleanup service + if (asyncProperties.enableCleanup()) { + AsyncCleanerService.init(elide, asyncProperties.getMaxRunTimeSeconds(), + asyncProperties.getQueryCleanupDays(), + asyncProperties.getQueryCancelCheckIntervalSeconds(), asyncAPIDao); + bind(AsyncCleanerService.getInstance()).to(AsyncCleanerService.class); + } + } + } + /** * Constructor. * @@ -109,118 +236,28 @@ protected void configure() { throw new IllegalStateException(e); } - // Bind to injector - register(new AbstractBinder() { - @Override - protected void configure() { - ElideStandaloneAsyncSettings asyncProperties = settings.getAsyncProperties() == null - ? new ElideStandaloneAsyncSettings() { } : settings.getAsyncProperties(); - EntityManagerFactory entityManagerFactory = Util.getEntityManagerFactory(classScanner, - settings.getModelPackageName(), asyncProperties.enabled(), settings.getDatabaseProperties()); - - EntityDictionary dictionary = settings.getEntityDictionary(injector, classScanner, dynamicConfiguration, - settings.getEntitiesToExclude()); - - DataStore dataStore; - - if (settings.getAnalyticProperties().enableAggregationDataStore()) { - MetaDataStore metaDataStore = settings.getMetaDataStore(classScanner, dynamicConfiguration); - if (metaDataStore == null) { - throw new IllegalStateException("Aggregation Datastore is enabled but metaDataStore is null"); - } - - DataSource defaultDataSource = Util.getDataSource(settings.getDatabaseProperties()); - ConnectionDetails defaultConnectionDetails = new ConnectionDetails(defaultDataSource, - SQLDialectFactory.getDialect(settings.getAnalyticProperties().getDefaultDialect())); - - QueryEngine queryEngine = settings.getQueryEngine(metaDataStore, defaultConnectionDetails, - dynamicConfiguration, settings.getDataSourceConfiguration(), - settings.getAnalyticProperties().getDBPasswordExtractor()); - AggregationDataStore aggregationDataStore = settings.getAggregationDataStore(queryEngine); - if (aggregationDataStore == null) { - throw new IllegalStateException( - "Aggregation Datastore is enabled but aggregationDataStore is null"); - } - dataStore = settings.getDataStore(metaDataStore, aggregationDataStore, entityManagerFactory); - } else { - dataStore = settings.getDataStore(entityManagerFactory); - } - ElideSettings elideSettings = settings.getElideSettings(dictionary, dataStore); - Elide elide = new Elide(elideSettings); - - // Bind elide instance for injection into endpoint - bind(elide).to(Elide.class).named("elide"); - - // Bind additional elements - bind(elideSettings).to(ElideSettings.class); - bind(elideSettings.getDictionary()).to(EntityDictionary.class); - bind(elideSettings.getDataStore()).to(DataStore.class).named("elideDataStore"); - - // Binding async service - if (asyncProperties.enabled()) { - AsyncAPIDAO asyncAPIDao = asyncProperties.getAPIDAO(); - if (asyncAPIDao == null) { - asyncAPIDao = new DefaultAsyncAPIDAO(elide.getElideSettings(), elide.getDataStore()); - } - bind(asyncAPIDao).to(AsyncAPIDAO.class); - - ExecutorService executor = (ExecutorService) servletContext.getAttribute(ASYNC_EXECUTOR_ATTR); - ExecutorService updater = (ExecutorService) servletContext.getAttribute(ASYNC_UPDATER_ATTR); - AsyncExecutorService asyncExecutorService = - new AsyncExecutorService(elide, executor, updater, asyncAPIDao); - bind(asyncExecutorService).to(AsyncExecutorService.class); - - if (asyncProperties.enableExport()) { - ExportApiProperties exportApiProperties = new ExportApiProperties( - asyncProperties.getExportAsyncResponseExecutor(), - asyncProperties.getExportAsyncResponseTimeoutSeconds()); - bind(exportApiProperties).to(ExportApiProperties.class).named("exportApiProperties"); - - ResultStorageEngine resultStorageEngine = asyncProperties.getResultStorageEngine(); - if (resultStorageEngine == null) { - resultStorageEngine = new FileResultStorageEngine(asyncProperties.getStorageDestination()); - } - bind(resultStorageEngine).to(ResultStorageEngine.class).named("resultStorageEngine"); - - // Initialize the Formatters. - Map supportedFormatters = new HashMap<>(); - supportedFormatters.put(ResultType.CSV, new CSVExportFormatter(elide, - asyncProperties.skipCSVHeader())); - supportedFormatters.put(ResultType.JSON, new JSONExportFormatter(elide)); - - // Binding TableExport LifeCycleHook - TableExportHook tableExportHook = getTableExportHook(asyncExecutorService, - asyncProperties, supportedFormatters, resultStorageEngine); - dictionary.bindTrigger(TableExport.class, READ, PRESECURITY, tableExportHook, false); - dictionary.bindTrigger(TableExport.class, CREATE, POSTCOMMIT, tableExportHook, false); - dictionary.bindTrigger(TableExport.class, CREATE, PRESECURITY, tableExportHook, false); - } - - // Binding AsyncQuery LifeCycleHook - AsyncQueryHook asyncQueryHook = new AsyncQueryHook(asyncExecutorService, - asyncProperties.getMaxAsyncAfterSeconds()); - - dictionary.bindTrigger(AsyncQuery.class, READ, PRESECURITY, asyncQueryHook, false); - dictionary.bindTrigger(AsyncQuery.class, CREATE, POSTCOMMIT, asyncQueryHook, false); - dictionary.bindTrigger(AsyncQuery.class, CREATE, PRESECURITY, asyncQueryHook, false); - - // Binding async cleanup service - if (asyncProperties.enableCleanup()) { - AsyncCleanerService.init(elide, asyncProperties.getMaxRunTimeSeconds(), - asyncProperties.getQueryCleanupDays(), - asyncProperties.getQueryCancelCheckIntervalSeconds(), asyncAPIDao); - bind(AsyncCleanerService.getInstance()).to(AsyncCleanerService.class); - } - } - } - }); + // Bind to injector + register(new ElideBinder(classScanner, dynamicConfiguration, servletContext)); // Bind swaggers to given endpoint + //This looks strange, but Jersey binds its Abstract binders first, and then later it binds 'external' + //binders (like this HK2 version). This allows breaking dependency injection into two phases. + //Everything bound in the first phase can be accessed in the second phase. register(new org.glassfish.hk2.utilities.binding.AbstractBinder() { @Override protected void configure() { - EntityDictionary dictionary = injector.getService(EntityDictionary.class); + Elide elide = injector.getService(Elide.class, "elide"); + + elide.doScans(); + + //Bind subscription hooks. + if (settings.getSubscriptionProperties().publishingEnabled()) { + settings.getSubscriptionProperties().subscriptionScanner(elide, + settings.getSubscriptionProperties().getConnectionFactory()); + } + + EntityDictionary dictionary = elide.getElideSettings().getDictionary(); if (settings.enableSwagger()) { List swaggerDocs = settings.buildSwagger(dictionary); diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneAnalyticSettings.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneAnalyticSettings.java index c0e2c5b89d..499cf12601 100644 --- a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneAnalyticSettings.java +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneAnalyticSettings.java @@ -1,5 +1,5 @@ /* - * Copyright 2020, Oath Inc. + * Copyright 2020, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -27,6 +27,15 @@ default boolean enableDynamicModelConfig() { return false; } + /** + * Enable support for reading and manipulating HJSON configuration through Elide models. + * + * @return Default: False + */ + default boolean enableDynamicModelConfigAPI() { + return false; + } + /** * Base path to Hjson dynamic model configurations. * diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneAsyncSettings.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneAsyncSettings.java index f3c3b1ba64..3cfd683430 100644 --- a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneAsyncSettings.java +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneAsyncSettings.java @@ -1,5 +1,5 @@ /* - * Copyright 2020, Oath Inc. + * Copyright 2020, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -117,6 +117,16 @@ default boolean enableExport() { return false; } + /** + * Enable the addition of extensions to Export attachments. + * If false, the attachments will be downloaded without extensions. + * + * @return Default: False + */ + default boolean enableExtension() { + return false; + } + /** * Skip generating Header when exporting in CSV format. * diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSettings.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSettings.java index 1098fa4f04..46d5857995 100644 --- a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSettings.java +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSettings.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -32,19 +32,25 @@ import com.yahoo.elide.datastores.aggregation.cache.CaffeineCache; import com.yahoo.elide.datastores.aggregation.core.Slf4jQueryLogger; import com.yahoo.elide.datastores.aggregation.metadata.MetaDataStore; +import com.yahoo.elide.datastores.aggregation.query.DefaultQueryPlanMerger; import com.yahoo.elide.datastores.aggregation.queryengines.sql.ConnectionDetails; import com.yahoo.elide.datastores.aggregation.queryengines.sql.DataSourceConfiguration; import com.yahoo.elide.datastores.aggregation.queryengines.sql.SQLQueryEngine; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; import com.yahoo.elide.datastores.aggregation.queryengines.sql.query.AggregateBeforeJoinOptimizer; +import com.yahoo.elide.datastores.aggregation.validator.TemplateConfigValidator; import com.yahoo.elide.datastores.jpa.JpaDataStore; import com.yahoo.elide.datastores.jpa.transaction.NonJtaTransaction; import com.yahoo.elide.datastores.multiplex.MultiplexManager; +import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.modelconfig.DBPasswordExtractor; import com.yahoo.elide.modelconfig.DynamicConfiguration; +import com.yahoo.elide.modelconfig.store.ConfigDataStore; +import com.yahoo.elide.modelconfig.store.models.ConfigChecks; import com.yahoo.elide.modelconfig.validator.DynamicConfigValidator; import com.yahoo.elide.swagger.SwaggerBuilder; import com.yahoo.elide.swagger.resources.DocEndpoint; +import org.apache.commons.lang3.StringUtils; import org.eclipse.jetty.servlet.ServletContextHandler; import org.glassfish.hk2.api.ServiceLocator; import org.glassfish.jersey.server.ResourceConfig; @@ -65,6 +71,7 @@ import java.util.Set; import java.util.TimeZone; import java.util.function.Consumer; +import java.util.function.Function; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; @@ -115,18 +122,20 @@ default Set> getEntitiesToExclude() { * * @param dictionary EntityDictionary object. * @param dataStore Dastore object + * @param mapper Object mapper * @return Configured ElideSettings object. */ - default ElideSettings getElideSettings(EntityDictionary dictionary, DataStore dataStore) { + default ElideSettings getElideSettings(EntityDictionary dictionary, DataStore dataStore, JsonApiMapper mapper) { ElideSettingsBuilder builder = new ElideSettingsBuilder(dataStore) .withEntityDictionary(dictionary) .withErrorMapper(getErrorMapper()) - .withJoinFilterDialect(new RSQLFilterDialect(dictionary)) - .withSubqueryFilterDialect(new RSQLFilterDialect(dictionary)) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) .withBaseUrl(getBaseUrl()) .withJsonApiPath(getJsonApiPathSpec().replaceAll("/\\*", "")) .withGraphQLApiPath(getGraphQLApiPathSpec().replaceAll("/\\*", "")) + .withJsonApiMapper(mapper) .withAuditLogger(getAuditLogger()); if (verboseErrors()) { @@ -244,6 +253,16 @@ default ElideStandaloneAnalyticSettings getAnalyticProperties() { return new ElideStandaloneAnalyticSettings() { }; } + /** + * Subscription Properties. + * + * @return SubscriptionProperties type object. + */ + default ElideStandaloneSubscriptionSettings getSubscriptionProperties() { + //Default Properties + return new ElideStandaloneSubscriptionSettings() { }; + } + /** * Whether Dates should be ISO8601 strings (true) or epochs (false). * @return whether ISO8601Dates are enabled. @@ -413,11 +432,24 @@ default DataSourceConfiguration getDataSourceConfiguration() { */ default DataStore getDataStore(MetaDataStore metaDataStore, AggregationDataStore aggregationDataStore, EntityManagerFactory entityManagerFactory) { + + List stores = new ArrayList<>(); + DataStore jpaDataStore = new JpaDataStore( () -> entityManagerFactory.createEntityManager(), - em -> new NonJtaTransaction(em, TXCANCEL, DEFAULT_LOGGER, true)); + em -> new NonJtaTransaction(em, TXCANCEL, DEFAULT_LOGGER, true, true)); + + stores.add(jpaDataStore); + + if (getAnalyticProperties().enableDynamicModelConfigAPI()) { + stores.add(new ConfigDataStore(getAnalyticProperties().getDynamicConfigPath(), + new TemplateConfigValidator(getClassScanner(), getAnalyticProperties().getDynamicConfigPath()))); + } + + stores.add(metaDataStore); + stores.add(aggregationDataStore); - return new MultiplexManager(jpaDataStore, metaDataStore, aggregationDataStore); + return new MultiplexManager(stores.toArray(new DataStore[0])); } /** @@ -428,7 +460,7 @@ default DataStore getDataStore(MetaDataStore metaDataStore, AggregationDataStore default DataStore getDataStore(EntityManagerFactory entityManagerFactory) { DataStore jpaDataStore = new JpaDataStore( () -> entityManagerFactory.createEntityManager(), - em -> new NonJtaTransaction(em, TXCANCEL, DEFAULT_LOGGER, true)); + em -> new NonJtaTransaction(em, TXCANCEL, DEFAULT_LOGGER, true, true)); return jpaDataStore; } @@ -459,6 +491,15 @@ default AggregationDataStore getAggregationDataStore(QueryEngine queryEngine) { default EntityDictionary getEntityDictionary(ServiceLocator injector, ClassScanner scanner, Optional dynamicConfiguration, Set> entitiesToExclude) { + Map> checks = new HashMap<>(); + + if (getAnalyticProperties().enableDynamicModelConfigAPI()) { + checks.put(ConfigChecks.CAN_CREATE_CONFIG, ConfigChecks.CanNotCreate.class); + checks.put(ConfigChecks.CAN_READ_CONFIG, ConfigChecks.CanNotRead.class); + checks.put(ConfigChecks.CAN_DELETE_CONFIG, ConfigChecks.CanNotDelete.class); + checks.put(ConfigChecks.CAN_UPDATE_CONFIG, ConfigChecks.CanNotUpdate.class); + } + EntityDictionary dictionary = new EntityDictionary( new HashMap<>(), //Checks new HashMap<>(), //Role Checks @@ -477,11 +518,10 @@ public T instantiate(Class cls) { entitiesToExclude, scanner); - dictionary.scanForSecurityChecks(); - dynamicConfiguration.map(DynamicConfiguration::getRoles).orElseGet(Collections::emptySet).forEach(role -> dictionary.addRoleCheck(role, new Role.RoleMemberCheck(role)) ); + return dictionary; } @@ -522,11 +562,22 @@ default QueryEngine getQueryEngine(MetaDataStore metaDataStore, ConnectionDetail dataSourceConfiguration.getDataSource(dbConfig, dbPasswordExtractor), SQLDialectFactory.getDialect(dbConfig.getDialect()))) ); - return new SQLQueryEngine(metaDataStore, defaultConnectionDetails, connectionDetailsMap, + + Function connectionDetailsLookup = (name) -> { + if (StringUtils.isEmpty(name)) { + return defaultConnectionDetails; + } + return Optional.ofNullable(connectionDetailsMap.get(name)) + .orElseThrow(() -> new IllegalStateException("ConnectionDetails undefined for connection: " + + name)); + }; + + return new SQLQueryEngine(metaDataStore, connectionDetailsLookup, new HashSet<>(Arrays.asList(new AggregateBeforeJoinOptimizer(metaDataStore))), + new DefaultQueryPlanMerger(metaDataStore), new DefaultQueryValidator(metaDataStore.getMetadataDictionary())); } - return new SQLQueryEngine(metaDataStore, defaultConnectionDetails); + return new SQLQueryEngine(metaDataStore, (unused) -> defaultConnectionDetails); } /** @@ -545,4 +596,13 @@ default ClassScanner getClassScanner() { default ErrorMapper getErrorMapper() { return error -> null; } + + /** + * Get the Jackson object mapper for Elide. + * + * @return object mapper. + */ + default JsonApiMapper getObjectMapper() { + return new JsonApiMapper(); + } } diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSubscriptionSettings.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSubscriptionSettings.java new file mode 100644 index 0000000000..61bbeee002 --- /dev/null +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/config/ElideStandaloneSubscriptionSettings.java @@ -0,0 +1,166 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package com.yahoo.elide.standalone.config; + +import static com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket.DEFAULT_USER_FACTORY; +import com.yahoo.elide.Elide; +import com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketConfigurator; +import com.yahoo.elide.graphql.subscriptions.hooks.SubscriptionScanner; +import com.yahoo.elide.graphql.subscriptions.websocket.SubscriptionWebSocket; + +import javax.jms.ConnectionFactory; +import javax.jms.Message; +import javax.websocket.server.ServerEndpointConfig; + +/** + * interface for configuring the GraphQL Subscriptions in the standalone application. + */ +public interface ElideStandaloneSubscriptionSettings { + + /** + * Enable support for subscriptions. + * + * @return Default: False + */ + default boolean enabled() { + return false; + } + + /** + * Enable support for subscriptions. + * + * @return Default: False + */ + default boolean publishingEnabled() { + return enabled(); + } + + /** + * Websocket root path for the subscription endpoint. + * + * @return Default: /subscription + */ + default String getPath() { + return "/subscription"; + } + + /** + * Websocket sends a PING immediate after receiving a SUBSCRIBE. Only useful for testing. + * + * @return Default false. + * @see com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketTestClient + */ + default boolean shouldSendPingOnSubscribe() { + return false; + } + + /** + * Time allowed in milliseconds from web socket creation to successfully receiving a CONNECTION_INIT message. + * + * @return Default 5000. + */ + default Integer getConnectionTimeoutMs() { + return 5000; + } + + /** + * Maximum number of outstanding GraphQL queries per websocket. + * + * @return Default 30 + */ + default Integer getMaxSubscriptions() { + return 30; + } + + /** + * Maximum message size that can be sent to the websocket. + * + * @return Default 10000 + */ + default Integer getMaxMessageSize() { + return 10000; + } + + /** + * Maximum idle timeout in milliseconds with no websocket activity. + * + * @return default 300000 + */ + default Long getIdleTimeoutMs() { + return 300000L; + } + + /** + * Return JMS connection factory. + * + * @return Default null. This must be implemented if leveraging GraphQL subscriptions. + */ + default ConnectionFactory getConnectionFactory() { + return null; + } + + /** + * Return the function which converts a web socket Session into an Elide user. + * + * @return default user factory. + */ + default SubscriptionWebSocket.UserFactory getUserFactory() { + return DEFAULT_USER_FACTORY; + } + + /** + * Returns the scanner that searches for subscription annotations and binds life cycle hooks for them. + * @param elide The elide instance. + * @param connectionFactory The JMS connection factory where subscription messages should be sent. + * @return The scanner. + */ + default SubscriptionScanner subscriptionScanner(Elide elide, ConnectionFactory connectionFactory) { + SubscriptionScanner scanner = SubscriptionScanner.builder() + + //Things you may want to override... + .deliveryDelay(Message.DEFAULT_DELIVERY_DELAY) + .messagePriority(Message.DEFAULT_PRIORITY) + .timeToLive(Message.DEFAULT_TIME_TO_LIVE) + .deliveryMode(Message.DEFAULT_DELIVERY_MODE) + + //Things you probably don't care about... + .scanner(elide.getScanner()) + .dictionary(elide.getElideSettings().getDictionary()) + .connectionFactory(connectionFactory) + .build(); + + scanner.bindLifecycleHooks(); + + return scanner; + } + + /** + * Returns the web socket configuration for GraphQL subscriptions. + * @param settings Elide settings. + * @return Web socket configuration. + */ + default ServerEndpointConfig serverEndpointConfig( + ElideStandaloneSettings settings + ) { + return ServerEndpointConfig.Builder + .create(SubscriptionWebSocket.class, getPath()) + .configurator(SubscriptionWebSocketConfigurator.builder() + .baseUrl(getPath()) + .sendPingOnSubscribe(shouldSendPingOnSubscribe()) + .connectionTimeoutMs(getConnectionTimeoutMs()) + .maxSubscriptions(getMaxSubscriptions()) + .maxMessageSize(getMaxMessageSize()) + .maxIdleTimeoutMs(getIdleTimeoutMs()) + .connectionFactory(getConnectionFactory()) + .userFactory(getUserFactory()) + .auditLogger(settings.getAuditLogger()) + .verboseErrors(settings.verboseErrors()) + .errorMapper(settings.getErrorMapper()) + .build()) + .build(); + + } +} diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/interfaces/CheckMappingsProvider.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/interfaces/CheckMappingsProvider.java index b3c35ad4dd..bb49a75739 100644 --- a/elide-standalone/src/main/java/com/yahoo/elide/standalone/interfaces/CheckMappingsProvider.java +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/interfaces/CheckMappingsProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-standalone/src/main/java/com/yahoo/elide/standalone/interfaces/ElideSettingsProvider.java b/elide-standalone/src/main/java/com/yahoo/elide/standalone/interfaces/ElideSettingsProvider.java index aa5bfd670b..f22a9e9f82 100644 --- a/elide-standalone/src/main/java/com/yahoo/elide/standalone/interfaces/ElideSettingsProvider.java +++ b/elide-standalone/src/main/java/com/yahoo/elide/standalone/interfaces/ElideSettingsProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2017, Oath Inc. + * Copyright 2017, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-standalone/src/test/java/example/ElideStandaloneConfigStoreTest.java b/elide-standalone/src/test/java/example/ElideStandaloneConfigStoreTest.java new file mode 100644 index 0000000000..0aa6574183 --- /dev/null +++ b/elide-standalone/src/test/java/example/ElideStandaloneConfigStoreTest.java @@ -0,0 +1,721 @@ +/* + * Copyright 2021, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ + +package example; + +import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.test.graphql.GraphQLDSL.argument; +import static com.yahoo.elide.test.graphql.GraphQLDSL.arguments; +import static com.yahoo.elide.test.graphql.GraphQLDSL.field; +import static com.yahoo.elide.test.graphql.GraphQLDSL.mutation; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selection; +import static com.yahoo.elide.test.graphql.GraphQLDSL.selections; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.data; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.links; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.when; +import static org.hamcrest.CoreMatchers.equalTo; +import com.yahoo.elide.core.dictionary.EntityDictionary; +import com.yahoo.elide.core.dictionary.Injector; +import com.yahoo.elide.core.exceptions.HttpStatus; +import com.yahoo.elide.core.security.checks.Check; +import com.yahoo.elide.core.security.checks.prefab.Role; +import com.yahoo.elide.core.type.Type; +import com.yahoo.elide.core.utils.ClassScanner; +import com.yahoo.elide.core.utils.coerce.CoerceUtil; +import com.yahoo.elide.modelconfig.DynamicConfiguration; +import com.yahoo.elide.modelconfig.store.models.ConfigChecks; +import com.yahoo.elide.standalone.ElideStandalone; +import com.yahoo.elide.standalone.config.ElideStandaloneAnalyticSettings; +import com.yahoo.elide.standalone.config.ElideStandaloneSettings; +import com.yahoo.elide.test.graphql.GraphQLDSL; +import org.glassfish.hk2.api.ServiceLocator; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.ws.rs.core.MediaType; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ElideStandaloneConfigStoreTest { + protected ElideStandalone elide; + protected ElideStandaloneSettings settings; + protected Path configRoot; + + @AfterAll + public void shutdown() throws Exception { + configRoot.toFile().delete(); + elide.stop(); + } + + @BeforeAll + public void init() throws Exception { + configRoot = Files.createTempDirectory("test"); + settings = new ElideStandaloneTestSettings() { + + @Override + public EntityDictionary getEntityDictionary(ServiceLocator injector, ClassScanner scanner, + Optional dynamicConfiguration, Set> entitiesToExclude) { + + Map> checks = new HashMap<>(); + + if (getAnalyticProperties().enableDynamicModelConfigAPI()) { + checks.put(ConfigChecks.CAN_CREATE_CONFIG, ConfigChecks.CanCreate.class); + checks.put(ConfigChecks.CAN_READ_CONFIG, ConfigChecks.CanRead.class); + checks.put(ConfigChecks.CAN_DELETE_CONFIG, ConfigChecks.CanDelete.class); + checks.put(ConfigChecks.CAN_UPDATE_CONFIG, ConfigChecks.CanNotUpdate.class); + } + + EntityDictionary dictionary = new EntityDictionary( + checks, //Checks + new HashMap<>(), //Role Checks + new Injector() { + @Override + public void inject(Object entity) { + injector.inject(entity); + } + + @Override + public T instantiate(Class cls) { + return injector.create(cls); + } + }, + CoerceUtil::lookup, //Serde Lookup + entitiesToExclude, + scanner); + + dynamicConfiguration.map(DynamicConfiguration::getRoles).orElseGet(Collections::emptySet).forEach(role -> + dictionary.addRoleCheck(role, new Role.RoleMemberCheck(role)) + ); + + return dictionary; + } + + @Override + public ElideStandaloneAnalyticSettings getAnalyticProperties() { + return new ElideStandaloneAnalyticSettings() { + @Override + public boolean enableDynamicModelConfig() { + return true; + } + + @Override + public boolean enableDynamicModelConfigAPI() { + return true; + } + + @Override + public String getDynamicConfigPath() { + return configRoot.toFile().getAbsolutePath(); + } + + @Override + public boolean enableAggregationDataStore() { + return true; + } + + @Override + public boolean enableMetaDataStore() { + return true; + } + }; + } + }; + + elide = new ElideStandalone(settings); + elide.start(false); + } + + /** + * Empty configuration load test. + */ + @Test + public void testEmptyConfiguration() { + when() + .get("/api/v1/config?fields[config]=path,type") + .then() + .body(equalTo("{\"data\":[]}")) + .statusCode(HttpStatus.SC_OK); + } + + @Test + public void testGraphQLNullContent() { + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "UPSERT"), + argument("data", "{ type: TABLE, path: \\\"models/tables/table1.hjson\\\" }") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("/graphql/api/v1") + .then() + .body(equalTo("{\"errors\":[{\"message\":\"Null or empty file content for models/tables/table1.hjson\"}]}")) + .log().all() + .statusCode(200); + } + + @Test + public void testTwoNamespaceCreationStatements() { + String query = "{ \"query\": \" mutation saveChanges {\\n one: config(op: UPSERT, data: {id:\\\"one\\\", path: \\\"models/namespaces/oneDemoNamespaces.hjson\\\", type: NAMESPACE, content: \\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace2\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\"}) {\\n edges {\\n node {\\n id\\n }\\n }\\n }\\n two: config(op: UPSERT, data: {id: \\\"two\\\", path: \\\"models/namespaces/twoDemoNamespaces.hjson\\\", type: NAMESPACE, content: \\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace3\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\"}) {\\n edges {\\n node {\\n id\\n }\\n }\\n }\\n} \" }"; + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(query) + .when() + .post("/graphql/api/v1") + .then() + .body(equalTo( + GraphQLDSL.document( + selections( + field( + "one", + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvb25lRGVtb05hbWVzcGFjZXMuaGpzb24=") + ) + ), + field( + "two", + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvdHdvRGVtb05hbWVzcGFjZXMuaGpzb24=") + ) + ) + ) + ).toResponse().replace(" ", "") + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "DELETE"), + argument("ids", "[\\\"bW9kZWxzL25hbWVzcGFjZXMvb25lRGVtb05hbWVzcGFjZXMuaGpzb24=\\\", \\\"bW9kZWxzL25hbWVzcGFjZXMvdHdvRGVtb05hbWVzcGFjZXMuaGpzb24=\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("/graphql/api/v1") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + + } + + @Test + public void testTwoNamespaceCreateAndDelete() { + String hjson1 = "\\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace2\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\""; + + String hjson2 = "\\\"{\\\\n namespaces:\\\\n [\\\\n {\\\\n name: DemoNamespace3\\\\n description: Namespace for Demo Purposes\\\\n friendlyName: Demo Namespace\\\\n }\\\\n ]\\\\n}\\\""; + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "UPSERT"), + argument("data", String.format("[" + + "{ type: NAMESPACE, path: \\\"models/namespaces/namespace2.hjson\\\", content: %s }," + + "{ type: NAMESPACE, path: \\\"models/namespaces/namespace3.hjson\\\", content: %s }" + + "]", hjson1, hjson2)) + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("/graphql/api/v1") + .then() + .body(equalTo( + GraphQLDSL.document( + selection( + field( + "config", + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMi5oanNvbg=="), + field("path", "models/namespaces/namespace2.hjson") + ), + selections( + field("id", "bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMy5oanNvbg=="), + field("path", "models/namespaces/namespace3.hjson") + ) + ) + ) + ).toResponse() + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "DELETE"), + argument("ids", "[\\\"bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMi5oanNvbg==\\\", \\\"bW9kZWxzL25hbWVzcGFjZXMvbmFtZXNwYWNlMy5oanNvbg==\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("/graphql/api/v1") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + + } + + @Test + public void testGraphQLCreateFetchAndDelete() { + String hjson = "\\\"{\\\\n" + + " tables: [{\\\\n" + + " name: Test\\\\n" + + " table: test\\\\n" + + " schema: test\\\\n" + + " measures : [\\\\n" + + " {\\\\n" + + " name : measure\\\\n" + + " type : INTEGER\\\\n" + + " definition: 'MAX({{$measure}})'\\\\n" + + " }\\\\n" + + " ]\\\\n" + + " dimensions : [\\\\n" + + " {\\\\n" + + " name : dimension\\\\n" + + " type : TEXT\\\\n" + + " definition : '{{$dimension}}'\\\\n" + + " }\\\\n" + + " ]\\\\n" + + " }]\\\\n" + + "}\\\""; + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "UPSERT"), + argument("data", String.format("{ type: TABLE, path: \\\"models/tables/table1.hjson\\\", content: %s }", hjson)) + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("/graphql/api/v1") + .then() + .body(equalTo( + GraphQLDSL.document( + selection( + field( + "config", + selections( + field("id", "bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + field("path", "models/tables/table1.hjson") + ) + ) + ) + ).toResponse() + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + selection( + field("config", + argument( + argument("ids", "[\\\"bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("/graphql/api/v1") + .then() + .body(equalTo( + GraphQLDSL.document( + selection( + field( + "config", + selections( + field("id", "bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + field("path", "models/tables/table1.hjson") + ) + ) + ) + ).toResponse() + )) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + mutation( + selection( + field("config", + arguments( + argument("op", "DELETE"), + argument("ids", "[\\\"bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=\\\"]") + ), + selections( + field("id"), + field("path") + ) + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("/graphql/api/v1") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + + given() + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body("{ \"query\" : \"" + GraphQLDSL.document( + selection( + field("config", + selections( + field("id"), + field("path") + ) + ) + ) + ).toQuery() + "\" }") + .when() + .post("/graphql/api/v1") + .then() + .body(equalTo("{\"data\":{\"config\":{\"edges\":[]}}}")) + .statusCode(200); + } + + @Test + public void testJsonApiCreateFetchAndDelete() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("/api/v1/config") + .then() + .statusCode(HttpStatus.SC_CREATED); + + when() + .get("/api/v1/config?fields[config]=content") + .then() + .body(equalTo(data( + resource( + type("config"), + id("bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + attributes( + attr("content", hjson) + ), + links( + attr("self", "https://elide.io/api/v1/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + ) + ) + ).toJSON())) + .statusCode(HttpStatus.SC_OK); + + when() + .delete("/api/v1/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + + when() + .get("/api/v1/config?fields[config]=path,type") + .then() + .body(equalTo("{\"data\":[]}")) + .statusCode(HttpStatus.SC_OK); + + when() + .get("/api/v1/json/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NOT_FOUND); + } + + @Test + public void testTemplateError() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}}) + {{$$column.args.missing}}'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("/api/v1/config") + .then() + .body(equalTo("{\"errors\":[{\"detail\":\"Failed to verify column arguments for column: measure in table: Test. Argument 'missing' is not defined but found '{{$$column.args.missing}}'.\"}]}")) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + @Test + public void testHackAttempt() { + String hjson = "#!/bin/sh ..."; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "foo"), + attr("type", "UNKNOWN"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("/api/v1/config") + .then() + .body(equalTo("{\"errors\":[{\"detail\":\"Unrecognized File: foo\"}]}")) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + @Test + public void testPathTraversalAttempt() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "../../../../../tmp/models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("/api/v1/config") + .then() + .body(equalTo("{\"errors\":[{\"detail\":\"Parent directory traversal not allowed: ../../../../../tmp/models/tables/table1.hjson\"}]}")) + .statusCode(HttpStatus.SC_BAD_REQUEST); + } + + @Test + public void testUpdatePermissionError() { + String hjson = "{ \n" + + " tables: [{ \n" + + " name: Test\n" + + " table: test\n" + + " schema: test\n" + + " measures : [\n" + + " {\n" + + " name : measure\n" + + " type : INTEGER\n" + + " definition: 'MAX({{$measure}})'\n" + + " }\n" + + " ] \n" + + " dimensions : [\n" + + " {\n" + + " name : dimension\n" + + " type : TEXT\n" + + " definition : '{{$dimension}}'\n" + + " }\n" + + " ]\n" + + " }]\n" + + "}"; + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .post("/api/v1/config") + .then() + .statusCode(HttpStatus.SC_CREATED); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("config"), + id("bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24="), + attributes( + attr("path", "models/tables/table1.hjson"), + attr("type", "TABLE"), + attr("content", hjson) + ) + ) + ) + ) + .when() + .patch("/api/v1/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_FORBIDDEN); + + when() + .delete("/api/v1/config/bW9kZWxzL3RhYmxlcy90YWJsZTEuaGpzb24=") + .then() + .statusCode(HttpStatus.SC_NO_CONTENT); + } +} diff --git a/elide-standalone/src/test/java/example/ElideStandaloneDisableAggStoreTest.java b/elide-standalone/src/test/java/example/ElideStandaloneDisableAggStoreTest.java index 476abb3e73..267654edd3 100644 --- a/elide-standalone/src/test/java/example/ElideStandaloneDisableAggStoreTest.java +++ b/elide-standalone/src/test/java/example/ElideStandaloneDisableAggStoreTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2020, Oath Inc. + * Copyright 2020, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-standalone/src/test/java/example/ElideStandaloneDisableMetaDataStoreTest.java b/elide-standalone/src/test/java/example/ElideStandaloneDisableMetaDataStoreTest.java index bf7fe329b7..3ac65dffcc 100644 --- a/elide-standalone/src/test/java/example/ElideStandaloneDisableMetaDataStoreTest.java +++ b/elide-standalone/src/test/java/example/ElideStandaloneDisableMetaDataStoreTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2020, Oath Inc. + * Copyright 2020, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-standalone/src/test/java/example/ElideStandaloneExportTest.java b/elide-standalone/src/test/java/example/ElideStandaloneExportTest.java index 07ea95d777..8a3f874d6f 100644 --- a/elide-standalone/src/test/java/example/ElideStandaloneExportTest.java +++ b/elide-standalone/src/test/java/example/ElideStandaloneExportTest.java @@ -119,7 +119,7 @@ public void shutdown() throws Exception { } @Test - public void exportNotFound() throws InterruptedException { + public void exportNotFound() { int queryId = 1; when() .get("/export/" + queryId) diff --git a/elide-standalone/src/test/java/example/ElideStandaloneMetadataStoreMissingTest.java b/elide-standalone/src/test/java/example/ElideStandaloneMetadataStoreMissingTest.java index 7879fd48b5..677a019b9b 100644 --- a/elide-standalone/src/test/java/example/ElideStandaloneMetadataStoreMissingTest.java +++ b/elide-standalone/src/test/java/example/ElideStandaloneMetadataStoreMissingTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2020, Oath Inc. + * Copyright 2020, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ diff --git a/elide-standalone/src/test/java/example/ElideStandaloneSubscriptionTest.java b/elide-standalone/src/test/java/example/ElideStandaloneSubscriptionTest.java new file mode 100644 index 0000000000..26c5843882 --- /dev/null +++ b/elide-standalone/src/test/java/example/ElideStandaloneSubscriptionTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2018, Yahoo Inc. + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in project root for terms. + */ +package example; + +import static com.yahoo.elide.Elide.JSONAPI_CONTENT_TYPE; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attr; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.attributes; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.datum; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.id; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.resource; +import static com.yahoo.elide.test.jsonapi.JsonApiDSL.type; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import com.yahoo.elide.datastores.jms.websocket.SubscriptionWebSocketTestClient; +import com.yahoo.elide.standalone.ElideStandalone; +import com.yahoo.elide.standalone.config.ElideStandaloneSubscriptionSettings; +import org.apache.activemq.artemis.core.config.Configuration; +import org.apache.activemq.artemis.core.config.impl.ConfigurationImpl; +import org.apache.activemq.artemis.core.server.JournalType; +import org.apache.activemq.artemis.core.server.embedded.EmbeddedActiveMQ; +import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; +import org.apache.http.HttpStatus; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import graphql.ExecutionResult; + +import java.net.URI; +import java.util.List; +import javax.jms.ConnectionFactory; +import javax.websocket.ContainerProvider; +import javax.websocket.Session; +import javax.websocket.WebSocketContainer; + +/** + * Tests ElideStandalone starts and works. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ElideStandaloneSubscriptionTest extends ElideStandaloneTest { + protected EmbeddedActiveMQ embedded; + + @BeforeAll + public void init() throws Exception { + settings = new ElideStandaloneTestSettings() { + + @Override + public ElideStandaloneSubscriptionSettings getSubscriptionProperties() { + return new ElideStandaloneSubscriptionSettings() { + @Override + public boolean enabled() { + return true; + } + + @Override + public ConnectionFactory getConnectionFactory() { + return new ActiveMQConnectionFactory("vm://0"); + } + }; + } + }; + + elide = new ElideStandalone(settings); + elide.start(false); + } + + @BeforeAll + public void initArtemis() throws Exception { + //Startup up an embedded active MQ. + embedded = new EmbeddedActiveMQ(); + Configuration configuration = new ConfigurationImpl(); + configuration.addAcceptorConfiguration("default", "vm://0"); + configuration.setPersistenceEnabled(false); + configuration.setSecurityEnabled(false); + configuration.setJournalType(JournalType.NIO); + + embedded.setConfiguration(configuration); + embedded.start(); + } + + @AfterAll + public void shutdown() throws Exception { + embedded.stop(); + elide.stop(); + } + + @Test + public void testSubscription() throws Exception { + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + + SubscriptionWebSocketTestClient client = new SubscriptionWebSocketTestClient(1, + List.of("subscription {post(topic: ADDED) {id content}}")); + + try (Session session = container.connectToServer(client, new URI("ws://localhost:" + settings.getPort() + "/subscription"))) { + + //Wait for the socket to be full established. + client.waitOnSubscribe(10); + + given() + .contentType(JSONAPI_CONTENT_TYPE) + .accept(JSONAPI_CONTENT_TYPE) + .body( + datum( + resource( + type("post"), + id("3"), + attributes( + attr("content", "This is my first post. woot."), + attr("date", "2019-01-01T00:00Z") + ) + ) + ) + ) + .post("/api/v1/post") + .then() + .statusCode(HttpStatus.SC_CREATED); + + List results = client.waitOnClose(10); + + client.sendClose(); + + assertEquals(1, results.size()); + assertEquals(0, results.get(0).getErrors().size()); + } + } +} diff --git a/elide-standalone/src/test/java/example/ElideStandaloneTest.java b/elide-standalone/src/test/java/example/ElideStandaloneTest.java index 1d5ce1b8a3..df85b516d8 100644 --- a/elide-standalone/src/test/java/example/ElideStandaloneTest.java +++ b/elide-standalone/src/test/java/example/ElideStandaloneTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -25,6 +25,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import com.yahoo.elide.standalone.ElideStandalone; +import com.yahoo.elide.standalone.config.ElideStandaloneSettings; import org.apache.http.HttpStatus; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -40,10 +41,13 @@ @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class ElideStandaloneTest { protected ElideStandalone elide; + protected ElideStandaloneSettings settings; @BeforeAll public void init() throws Exception { - elide = new ElideStandalone(new ElideStandaloneTestSettings()); + settings = new ElideStandaloneTestSettings(); + + elide = new ElideStandalone(settings); elide.start(false); } @@ -71,7 +75,6 @@ public void testJsonAPIPost() { ) .post("/api/v1/post") .then() - .statusCode(HttpStatus.SC_CREATED); // Test the Dynamic Generated Analytical Model is accessible @@ -263,7 +266,7 @@ public void testAsyncApiEndpoint() throws InterruptedException { // Resource disabled by default. @Test - public void exportResourceDisabledTest() throws InterruptedException { + public void exportResourceDisabledTest() { // elide-standalone returns different error message when export resource is not initialized // vs when it could not find a matching id to export. // Jetty seems to have a different behavior than spring-framework for non-existent resources. diff --git a/elide-standalone/src/test/java/example/ElideStandaloneTestSettings.java b/elide-standalone/src/test/java/example/ElideStandaloneTestSettings.java index 38167d9acb..d23ab6c2b2 100644 --- a/elide-standalone/src/test/java/example/ElideStandaloneTestSettings.java +++ b/elide-standalone/src/test/java/example/ElideStandaloneTestSettings.java @@ -11,14 +11,19 @@ import com.yahoo.elide.core.dictionary.EntityDictionary; import com.yahoo.elide.core.filter.dialect.RSQLFilterDialect; import com.yahoo.elide.datastores.aggregation.queryengines.sql.dialects.SQLDialectFactory; +import com.yahoo.elide.jsonapi.JsonApiMapper; import com.yahoo.elide.jsonapi.links.DefaultJSONApiLinks; import com.yahoo.elide.standalone.config.ElideStandaloneAnalyticSettings; import com.yahoo.elide.standalone.config.ElideStandaloneAsyncSettings; import com.yahoo.elide.standalone.config.ElideStandaloneSettings; +import com.yahoo.elide.standalone.config.ElideStandaloneSubscriptionSettings; import example.models.Post; +import org.apache.activemq.artemis.jms.client.ActiveMQConnectionFactory; + import java.util.Properties; import java.util.TimeZone; +import javax.jms.ConnectionFactory; /** * Settings class extending ElideStandaloneSettings for tests. @@ -26,7 +31,7 @@ public class ElideStandaloneTestSettings implements ElideStandaloneSettings { @Override - public ElideSettings getElideSettings(EntityDictionary dictionary, DataStore dataStore) { + public ElideSettings getElideSettings(EntityDictionary dictionary, DataStore dataStore, JsonApiMapper mapper) { String jsonApiBaseUrl = getBaseUrl() + getJsonApiPathSpec().replaceAll("/\\*", "") + "/"; @@ -34,12 +39,13 @@ public ElideSettings getElideSettings(EntityDictionary dictionary, DataStore dat ElideSettingsBuilder builder = new ElideSettingsBuilder(dataStore) .withEntityDictionary(dictionary) .withErrorMapper(getErrorMapper()) - .withJoinFilterDialect(new RSQLFilterDialect(dictionary)) - .withSubqueryFilterDialect(new RSQLFilterDialect(dictionary)) + .withJoinFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) + .withSubqueryFilterDialect(RSQLFilterDialect.builder().dictionary(dictionary).build()) .withJSONApiLinks(new DefaultJSONApiLinks(jsonApiBaseUrl)) .withBaseUrl("https://elide.io") .withAuditLogger(getAuditLogger()) .withVerboseErrors() + .withJsonApiMapper(mapper) .withJsonApiPath(getJsonApiPathSpec().replaceAll("/\\*", "")) .withGraphQLApiPath(getGraphQLApiPathSpec().replaceAll("/\\*", "")) .withExportApiPath(getAsyncProperties().getExportApiPathSpec().replaceAll("/\\*", "")); @@ -159,4 +165,24 @@ public String getDynamicConfigPath() { }; return analyticProperties; } + + @Override + public ElideStandaloneSubscriptionSettings getSubscriptionProperties() { + return new ElideStandaloneSubscriptionSettings() { + @Override + public boolean enabled() { + return false; + } + + @Override + public boolean shouldSendPingOnSubscribe() { + return true; + } + + @Override + public ConnectionFactory getConnectionFactory() { + return new ActiveMQConnectionFactory("vm://0"); + } + }; + } } diff --git a/elide-standalone/src/test/java/example/models/Post.java b/elide-standalone/src/test/java/example/models/Post.java index 3361cfe55d..fa3252f414 100644 --- a/elide-standalone/src/test/java/example/models/Post.java +++ b/elide-standalone/src/test/java/example/models/Post.java @@ -1,5 +1,5 @@ /* - * Copyright 2018, Oath Inc. + * Copyright 2018, Yahoo Inc. * Licensed under the Apache License, Version 2.0 * See LICENSE file in project root for terms. */ @@ -9,6 +9,8 @@ import com.yahoo.elide.annotation.CreatePermission; import com.yahoo.elide.annotation.Include; import com.yahoo.elide.annotation.UpdatePermission; +import com.yahoo.elide.graphql.subscriptions.annotations.Subscription; +import com.yahoo.elide.graphql.subscriptions.annotations.SubscriptionField; import example.checks.AdminCheck; import lombok.Data; @@ -22,14 +24,17 @@ @Entity @Include @Data +@Subscription public class Post { @Id private long id; @Column(nullable = false) + @SubscriptionField private String content; @Temporal(TemporalType.TIMESTAMP) + @SubscriptionField private Date date; @CreatePermission(expression = AdminCheck.USER_IS_ADMIN) diff --git a/elide-swagger/pom.xml b/elide-swagger/pom.xml index 1010a912a9..7e2233ed6d 100644 --- a/elide-swagger/pom.xml +++ b/elide-swagger/pom.xml @@ -14,7 +14,7 @@ elide-parent-pom com.yahoo.elide - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -42,7 +42,7 @@ com.yahoo.elide elide-core - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -54,7 +54,7 @@ com.yahoo.elide elide-integration-tests - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT test-jar test @@ -70,7 +70,7 @@ com.google.code.gson gson - 2.8.8 + 2.9.1 javax.persistence @@ -85,7 +85,14 @@ io.swagger swagger-core - 1.6.2 + 1.6.8 + + + + + javax.xml.bind + jaxb-api + 2.3.1 org.glassfish.jersey.containers @@ -126,14 +133,6 @@ org.apache.maven.plugins maven-checkstyle-plugin - - org.apache.maven.plugins - maven-compiler-plugin - - 1.8 - 1.8 - - diff --git a/elide-swagger/src/main/java/com/yahoo/elide/swagger/SwaggerBuilder.java b/elide-swagger/src/main/java/com/yahoo/elide/swagger/SwaggerBuilder.java index 9407925ea9..5c611c286f 100644 --- a/elide-swagger/src/main/java/com/yahoo/elide/swagger/SwaggerBuilder.java +++ b/elide-swagger/src/main/java/com/yahoo/elide/swagger/SwaggerBuilder.java @@ -433,7 +433,7 @@ private Path decorateGlobalResponses(Path path) { */ private Parameter getSparseFieldsParameter() { String typeName = dictionary.getJsonAliasFor(type); - List fieldNames = dictionary.getAllFields(type); + List fieldNames = dictionary.getAllExposedFields(type); return new QueryParameter() .type("array") diff --git a/elide-test/pom.xml b/elide-test/pom.xml index 14957001a0..d28b1e6fd3 100644 --- a/elide-test/pom.xml +++ b/elide-test/pom.xml @@ -14,7 +14,7 @@ elide-parent-pom com.yahoo.elide - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT @@ -42,12 +42,12 @@ com.google.code.gson gson - 2.8.8 + 2.9.1 com.graphql-java graphql-java - 17.2 + 19.2 com.fasterxml.jackson.core @@ -96,14 +96,6 @@ org.apache.maven.plugins maven-checkstyle-plugin - - org.apache.maven.plugins - maven-compiler-plugin - - 1.8 - 1.8 - - diff --git a/elide-test/src/main/java/com/yahoo/elide/test/graphql/elements/Field.java b/elide-test/src/main/java/com/yahoo/elide/test/graphql/elements/Field.java index c4c1a05744..903677ce62 100644 --- a/elide-test/src/main/java/com/yahoo/elide/test/graphql/elements/Field.java +++ b/elide-test/src/main/java/com/yahoo/elide/test/graphql/elements/Field.java @@ -93,6 +93,10 @@ public String toResponse() { : getSelectionSet().toString() ); } + + if (getSelectionSet() == null) { + return String.format("\"%s\":%s", getName(), null); + } // object response field return String.format("\"%s\":%s", getName(), ((SelectionSet) getSelectionSet()).toResponse()); } diff --git a/pom.xml b/pom.xml index 457698bf4e..932454e3d5 100644 --- a/pom.xml +++ b/pom.xml @@ -8,7 +8,7 @@ 4.0.0 com.yahoo.elide elide-parent-pom - 6.0.0-pr1-SNAPSHOT + 6.1.10-SNAPSHOT pom Elide: Parent Pom Parent pom for Elide project @@ -85,20 +85,22 @@ ${project.basedir}/target/lombok - 4.9.2 - 1.2.5 - 9.4.43.v20210629 - 4.4.0 - 2.12.5 - 2.32 - 5.7.2 - 1.7.2 - 1.18.20 - 2.6.0 - 3.6.10.Final - 5.5.5.Final - 1.7.32 - 3.9.1 + 4.9.3 + 10.0.10 + 1.2.11 + 5.2.0 + 2.14.0 + 2.14.1 + 2.35 + 5.9.1 + 1.9.1 + 1.18.24 + 2.7.0 + 5.6.14.Final + 2.0.3 + 4.3.0 + 0.11.0 + 3.12.1 true @@ -128,12 +130,12 @@ org.slf4j log4j-over-slf4j - 1.7.32 + 2.0.3 com.google.guava guava - 30.1.1-jre + 31.1-jre javax.inject @@ -234,13 +236,13 @@ com.h2database h2 - 1.4.200 + 2.1.214 test org.mockito mockito-core - 3.12.4 + 4.8.0 test @@ -256,7 +258,7 @@ com.fasterxml.jackson.core jackson-databind - ${version.jackson} + ${version.jackson.databind} com.fasterxml.jackson.dataformat @@ -365,7 +367,7 @@ org.sonatype.plugins nexus-staging-maven-plugin - 1.6.8 + 1.6.13 true ossrh @@ -384,7 +386,7 @@ org.apache.maven.plugins maven-surefire-plugin - 3.0.0-M5 + 3.0.0-M7 org.apache.maven.plugins @@ -394,7 +396,7 @@ org.apache.maven.plugins maven-checkstyle-plugin - 3.1.2 + 3.2.0 validate @@ -421,7 +423,7 @@ com.puppycrawl.tools checkstyle - 9.0 + 10.4 compile @@ -429,12 +431,12 @@ org.apache.maven.plugins maven-deploy-plugin - 2.8.2 + 3.0.0 org.apache.maven.plugins maven-jar-plugin - 3.2.0 + 3.3.0 maven-site-plugin @@ -466,7 +468,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.8.1 + 3.10.1 ${source.jdk.version} ${target.jdk.version} @@ -518,7 +520,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 3.3.0 + 3.4.1 attach-javadocs @@ -531,6 +533,7 @@ all,-missing ${source.jdk.version} ${delombok.output} + ${project.basedir}/src/main/java @@ -541,13 +544,13 @@ org.apache.maven.scm maven-scm-provider-gitexe - 1.11.3 + 1.13.0 org.apache.maven.scm maven-scm-api - 1.11.3 + 1.13.0 @@ -557,7 +560,7 @@ org.jacoco jacoco-maven-plugin - 0.8.7 + 0.8.8 default-prepare-agent @@ -639,7 +642,7 @@ org.apache.maven.plugins maven-enforcer-plugin - 3.0.0 + 3.1.0 enforce-java @@ -656,32 +659,12 @@ - - org.owasp - dependency-check-maven - 6.2.2 - - 7 - true - true - false - suppressions.xml - false - - - - - check - - - - org.apache.maven.wagon wagon-ssh-external - 3.4.3 + 3.5.2 diff --git a/screwdriver.yaml b/screwdriver.yaml index ec99b11e0e..2d283eae79 100644 --- a/screwdriver.yaml +++ b/screwdriver.yaml @@ -8,7 +8,7 @@ shared: annotations: screwdriver.cd/cpu: TURBO screwdriver.cd/ram: TURBO - image: maven:3.6.3-jdk-8 + environment: #Fetches history so Sonar can assign blame. GIT_SHALLOW_CLONE: false @@ -31,6 +31,7 @@ jobs: - build: mvn -B clean verify coveralls:report elide-5-build: + image: maven:3.6.3-jdk-8 requires: [~pr:elide-5.x, ~commit:elide-5.x] secrets: - COVERALLS_REPO_TOKEN @@ -38,21 +39,36 @@ jobs: - build: mvn -B clean verify coveralls:report elide-4-build: + image: maven:3.6.3-jdk-8 requires: [~pr:elide-4.x, ~commit:elide-4.x] secrets: - COVERALLS_REPO_TOKEN steps: - build: mvn -B clean verify coveralls:report - release: + release-java11: + image: maven:3.8.2-openjdk-11 secrets: - GPG_KEYNAME - GPG_PASSPHRASE - GPG_ENCPHRASE - OSSRH_USER - OSSRH_TOKEN - requires: [~tag, ~release] + requires: [~tag:/^6/, ~release:/^6/] steps: - build: "screwdriver/scripts/build.sh" - publish: "screwdriver/scripts/publish.sh" + + release-java8: + image: maven:3.6.3-jdk-8 + secrets: + - GPG_KEYNAME + - GPG_PASSPHRASE + - GPG_ENCPHRASE + - OSSRH_USER + - OSSRH_TOKEN + requires: [~tag:/^4|5/, ~release:/^4|5/] + steps: + - build: "screwdriver/scripts/build.sh" + - publish: "screwdriver/scripts/publish.sh" diff --git a/suppressions.xml b/suppressions.xml index 0978a0c96e..073624ab23 100644 --- a/suppressions.xml +++ b/suppressions.xml @@ -1,35 +1,48 @@ - - - ^com\.fasterxml\.jackson\.core:jackson-databind.*$ - CVE-2019-16335 - CVE-2019-14540 + + + + + CVE-2020-0822 - - - CVE-2020-11050 + + + + CVE-2016-1000027 - - - CVE-2018-1258 + + + CVE-2022-29885 - - - CVE-2020-25638 + + + CVE-2017-12629 - - - CVE-2020-25638 - CVE-2019-14900 + + + CVE-2022-22978 - - - CVE-2020-0822 + + + CVE-2022-33915 + + + + CVE-2022-31569 + + + + + CVE-2022-37734 + + + +>>>>>>> master