diff --git a/.rubocop.yml b/.rubocop.yml index 9639ac52..d8c2f6e5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -392,6 +392,9 @@ Security/JSONLoad: Security/MarshalLoad: Enabled: true + Exclude: + # Round-tripping records through Marshal is what this file tests. + - 'test/unit/serialization_test.rb' Security/Open: Enabled: true diff --git a/CHANGELOG.md b/CHANGELOG.md index a3395e02..7123a3d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,30 @@ ## next / unreleased +### Breaking change: serialized payloads carry the tenant + +A tenanted record now writes its tenant into the payload produced by `Marshal`, JSON, YAML, and MessagePack, so that it keeps its tenant identity when it is loaded in another tenant context or in none. This fixes a hang in parallel test suites, where a worker died reporting an exception that carried a record with a loaded association. See [#243](https://github.com/basecamp/activerecord-tenanted/issues/243). @flavorjones + +The payload format changes, and the change is not backward compatible. An earlier version of this gem reading a payload written by this version raises `ActiveModel::UnknownAttributeError` for JSON, and gains a phantom `tenant` attribute for `Marshal` and MessagePack. Payloads written by an earlier version are still read correctly by this version, and keep the loading context as they did before. + +Discard persisted payloads before upgrading, including cache entries, enqueued jobs, and serialized columns. Expect the same incompatibility during a rolling deploy, while old and new code run at the same time. + +### Breaking change: record equality + +`==`, `eql?`, and `#hash` now take the tenant into account. Two records of the same class with the same id in different tenant databases were previously equal, and collided as `Hash` keys and in a `Set`. They are now distinct. Code that compares records across tenants, or that relies on `has_many` assignment treating them as interchangeable, will behave differently. @flavorjones + ### Fixed - `Tenanted::GlobalId::Locator` now inherits from `GlobalID::Locator::UnscopedLocator`, which is the locator Rails uses by default. Two behavior changes follow: GlobalID lookups no longer apply a model's `default_scope`, matching Rails; and `#locate_many` is now implemented, which Active Job on Rails edge requires to deserialize GlobalID arguments. `#locate_many` enforces the same tenant safety checks as `#locate`. @flavorjones - `Tenanted::GlobalId::Locator` no longer emits a deprecation warning from GlobalID 1.4.0 about the missing `model_class` method. @flavorjones +- The tenant context check now runs when an association reads or writes the database, rather than when the `Association` object is created. A polymorphic association is checked against the class it points at, instead of being presumed tenanted. @flavorjones +- `#reload`, `#destroy`, `#delete`, `#update_column`, `#update_columns`, `#touch`, `#increment!`, `#decrement!`, and `#valid?` check the tenant context before they touch the database. They previously operated on the current tenant's database whatever the record's tenant. @flavorjones +- Assigning a `belongs_to` target that belongs to another tenant now raises `WrongTenantError` when the record is saved, rather than writing a foreign key that points into another tenant's database. @flavorjones +- `UntenantedConnectionPool` implements `#clear_query_cache` as a no-op, having no query cache to clear. `ActiveRecord::Persistence#reload` calls it before it asks for a connection, so an untenanted `#reload` previously raised `NoMethodError` instead of `NoTenantError`. @flavorjones + +### Developer infrastructure + +- Added `msgpack` as a development dependency, so that the serialization tests can exercise a MessagePack round trip. @flavorjones ## v0.7.0 / 2026-06-08 diff --git a/Gemfile b/Gemfile index 09af04f8..dc83eced 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,7 @@ group :development, :test do gem "minitest", "6.0.6" gem "minitest-mock", "5.27.0" gem "minitest-parallel_fork", "2.1.1", require: false + gem "msgpack", "1.8.3" gem "sqlite3", "2.9.4" end diff --git a/Gemfile.lock b/Gemfile.lock index e8e410e4..2dbeec99 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -187,6 +187,7 @@ GEM minitest-mock (5.27.0) minitest-parallel_fork (2.1.1) minitest (>= 5.15.0) + msgpack (1.8.3) net-imap (0.6.6) date net-protocol @@ -197,6 +198,14 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) nokogiri (1.19.4-x86_64-darwin) @@ -315,6 +324,10 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) + sqlite3 (2.9.4-aarch64-linux-gnu) + sqlite3 (2.9.4-aarch64-linux-musl) + sqlite3 (2.9.4-arm-linux-gnu) + sqlite3 (2.9.4-arm-linux-musl) sqlite3 (2.9.4-arm64-darwin) sqlite3 (2.9.4-x86_64-darwin) sqlite3 (2.9.4-x86_64-linux-gnu) @@ -325,6 +338,9 @@ GEM tailwindcss-rails (4.6.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.3.3) + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) + tailwindcss-ruby (4.3.3-aarch64-linux-musl) tailwindcss-ruby (4.3.3-arm64-darwin) tailwindcss-ruby (4.3.3-x86_64-darwin) tailwindcss-ruby (4.3.3-x86_64-linux-gnu) @@ -352,9 +368,12 @@ GEM zeitwerk (2.8.3) PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl arm64-darwin x86_64-darwin - x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -368,6 +387,7 @@ DEPENDENCIES minitest (= 6.0.6) minitest-mock (= 5.27.0) minitest-parallel_fork (= 2.1.1) + msgpack (= 1.8.3) propshaft puma (>= 5.0) rails! @@ -385,5 +405,143 @@ DEPENDENCIES tailwindcss-rails turbo-rails +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.2.0.alpha) + actionmailbox (8.2.0.alpha) + actionmailer (8.2.0.alpha) + actionpack (8.2.0.alpha) + actiontext (8.2.0.alpha) + actionview (8.2.0.alpha) + activejob (8.2.0.alpha) + activemodel (8.2.0.alpha) + activerecord (8.2.0.alpha) + activerecord-tenanted (0.7.0) + activestorage (8.2.0.alpha) + activesupport (8.2.0.alpha) + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + appraisal (2.5.0) sha256=36989221be127913b0dba8d114da2001e6b2dceea7bd4951200eaba764eed3ce + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler (4.0.13) sha256=19f08be7f27022cf0b89f27da0b044ae075e8270a9ef44ad248a932614e1ca3b + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.1) sha256=007e2685b1d873415a7587ef889336c6dcdf8a47fc9c9a63547582b21660a11b + fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + minitest-mock (5.27.0) sha256=7040ed7185417a966920987eaa6eaf1be4ea1fc5b25bb03ff4703f98564a55b0 + minitest-parallel_fork (2.1.1) sha256=c2ce22a65f7b5a35528f98ef15e388e7cf1630d0107bca0c7112cb5e441efb9e + msgpack (1.8.3) sha256=8bda4a6428d3244e50d6bd55854d354edbada88a4e1f4f5731a39a0f86bee6a1 + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 + nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + ractor-dispatch (0.2.0) sha256=5fe3eb85c202ac33910b0ada80a15f7a2e9504a057cf91d90ce80a73608492b2 + rails (8.2.0.alpha) + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + railties (8.2.0.alpha) + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.1.2) sha256=050eb1d8b508f1233bed929c0f2c7052302f7adf295230d9cb314e9024078f48 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rubocop (1.89.0) sha256=4dee8e3ee9c45e474834efd9e8d6fd031e8331c8dacdff0de4ad65ae0a6faae7 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-minitest (0.39.1) sha256=998398d6da4026d297f0f9bf709a1eac5f2b6947c24431f94af08138510cf7ed + rubocop-packaging (0.6.0) sha256=fb92bd0fb48e6f8cdb1648d2249b0cd51c2497dcc87340132d22f01edbf558a7 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.35.3) sha256=6edd45410866912b9b2e90ae3aeafd31d576df2bb2a9c9408f1667a50c32c7de + rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + rubyzip (3.4.1) sha256=0a79e745b5c25872ebd148457df5665da8530ed8626c993b01457128f173ca02 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.46.0) sha256=cb6cfa6f79eac041749df0b14cdcb0e9fe5df3715a64c77bfaa56b345b99f957 + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.6.0) sha256=b5fc3bb34162e09d8f960df6400d0f9304dc80fe66c2cfface31c32b228de6a1 + sqlite3 (2.9.4-aarch64-linux-gnu) sha256=ecabed721e6eaad54601d2685f09029d90025efc8d931040dc89cb3f8a2080ec + sqlite3 (2.9.4-aarch64-linux-musl) sha256=ffb4255947fb54c8c3eeca97460c9702b40de91ce390455ef7367ca6a3929a31 + sqlite3 (2.9.4-arm-linux-gnu) sha256=9ee2008b9fbec984c3c165b0d7eedd2bd2a415100b761bfa3a4c6fbec9208bf6 + sqlite3 (2.9.4-arm-linux-musl) sha256=8dc1fe4da6977992cd62decf4a93ccf6cc2e124a5e6a340160d52092f70e837a + sqlite3 (2.9.4-arm64-darwin) sha256=1d5aad413a815d236e96d43f05a1acc600b6cd086800770342a3f9c2877499ff + sqlite3 (2.9.4-x86_64-darwin) sha256=f280c476e360b73e86165a5e59b72801385b4a6c3a47f8af5ecefb9d90bec17f + sqlite3 (2.9.4-x86_64-linux-gnu) sha256=537a3eda71b1df1336d0055cbebe55a7317c34870c192c7b6b9d8d0be6871847 + sqlite3 (2.9.4-x86_64-linux-musl) sha256=3fc5e865b4be9a85d998203ef8d0c0fdcb92f20acf34a254346ff8a19088efec + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + tailwindcss-rails (4.6.0) sha256=d99512867173d55c5ef8890427682299d8539f550cec1408b3d8667a538bd365 + tailwindcss-ruby (4.3.3) sha256=ee0a64030749862deb501acab4c4aaf5adbee13865746a33299d46d7b5d0952a + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) sha256=c86d6dd3eccc85fe0d792a832b06f2bf3c0a7a83b399308aeb9d8f5725f42a6a + tailwindcss-ruby (4.3.3-aarch64-linux-musl) sha256=72b77ca9edea82383dd09510ab520a122e3cb9f9864ca5b38e27698098d2b899 + tailwindcss-ruby (4.3.3-arm64-darwin) sha256=776c51fc734aac64bde0c253eadd2ed2e610b2ba0d8e760501fe7cbec55fd6cf + tailwindcss-ruby (4.3.3-x86_64-darwin) sha256=483011e5d3792cf5ef4afdfb04c1d95daccf42c30cdb6fe53b77520954e7b922 + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) sha256=2337017ff8b02698480eae1e9637cf01faa0e4824db89d067a13c5a5ee38c9b2 + tailwindcss-ruby (4.3.3-x86_64-linux-musl) sha256=27d478c417bcf73828e5b544744c5bdfd5b5cb54f1a266fc4f185281c32efe6c + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + BUNDLED WITH - 2.7.2 + 4.0.13 diff --git a/gemfiles/rails_8_1.gemfile b/gemfiles/rails_8_1.gemfile index 0c84ff17..32c62cad 100644 --- a/gemfiles/rails_8_1.gemfile +++ b/gemfiles/rails_8_1.gemfile @@ -22,6 +22,7 @@ group :development, :test do gem "minitest", "6.0.6" gem "minitest-mock", "5.27.0" gem "minitest-parallel_fork", "2.1.1", require: false + gem "msgpack", "1.8.3" gem "sqlite3", "2.9.4" end diff --git a/gemfiles/rails_edge.gemfile b/gemfiles/rails_edge.gemfile index fcfab00e..09529d2e 100644 --- a/gemfiles/rails_edge.gemfile +++ b/gemfiles/rails_edge.gemfile @@ -22,6 +22,7 @@ group :development, :test do gem "minitest", "6.0.6" gem "minitest-mock", "5.27.0" gem "minitest-parallel_fork", "2.1.1", require: false + gem "msgpack", "1.8.3" gem "sqlite3", "2.9.4" end diff --git a/lib/active_record/tenanted/associations.rb b/lib/active_record/tenanted/associations.rb new file mode 100644 index 00000000..032419e4 --- /dev/null +++ b/lib/active_record/tenanted/associations.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + # Tenant context is checked when an association reads or writes the database, and not when the + # Association object is created. Serializers create associations to dump and restore their + # targets without ever going near a connection, and must not be tripped up by the check. + # + # `klass` is resolved at both of these seams, so a polymorphic association is checked against + # the class it actually points at rather than being presumed tenanted. + module Associations # :nodoc: + # Every query built on behalf of an association funnels through here, including the ones + # issued by a collection proxy that has outlived the tenant context it was created in. + def scope + ensure_owner_tenant_context_safety + super + end + + private + # Called by the collection and singular association readers, so that the exception is + # raised at the call site that made the mistake and not at the eventual query. + def ensure_klass_exists! + super + ensure_owner_tenant_context_safety + end + + def ensure_owner_tenant_context_safety + owner.ensure_tenant_context_safety if owner.class.tenanted? && klass&.tenanted? + end + end + end +end diff --git a/lib/active_record/tenanted/message_pack.rb b/lib/active_record/tenanted/message_pack.rb new file mode 100644 index 00000000..999511ef --- /dev/null +++ b/lib/active_record/tenanted/message_pack.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module MessagePack # :nodoc: + # This patch sees every record in a payload, unlike the Marshal, YAML, and JSON hooks, which + # are methods on the record and so only ever run for tenanted models. + module Decoder + def build_record(entry) + class_name, attributes_hash, * = entry + + if ActiveSupport::MessagePack::Extensions.load_class(class_name).tenanted? + has_tenant = attributes_hash.key?("tenant") + tenant_name = attributes_hash.delete("tenant") + + super.tap do |record| + record.instance_variable_set(:@tenant, tenant_name) if has_tenant + end + else + super + end + end + end + end + end +end diff --git a/lib/active_record/tenanted/railtie.rb b/lib/active_record/tenanted/railtie.rb index ca9aae3a..a4292a94 100644 --- a/lib/active_record/tenanted/railtie.rb +++ b/lib/active_record/tenanted/railtie.rb @@ -75,6 +75,7 @@ class Railtie < ::Rails::Railtie ActiveSupport.on_load(:active_record) do prepend ActiveRecord::Tenanted::Base ActiveRecord::Relation.prepend ActiveRecord::Tenanted::Relation + ActiveRecord::Associations::Association.prepend ActiveRecord::Tenanted::Associations end end @@ -93,6 +94,13 @@ class Railtie < ::Rails::Railtie config.active_record.check_schema_cache_dump_version = false end + initializer "active_record_tenanted.message_pack" do + ActiveSupport.on_load(:message_pack) do + require "active_record/message_pack" + ActiveRecord::MessagePack::Decoder.prepend ActiveRecord::Tenanted::MessagePack::Decoder + end + end + initializer "active_record_tenanted.monkey_patches" do ActiveSupport.on_load(:active_record) do ActiveRecord::Tenanted::Patches::Attributes.apply_patch diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index dc91b979..c8ed1fd4 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -9,13 +9,24 @@ module TenantCommon # :nodoc: prepended do attr_reader :tenant - before_save :ensure_tenant_context_safety + before_save :ensure_belongs_to_tenant_safety, prepend: true + before_save :ensure_tenant_context_safety, prepend: true + before_destroy :ensure_tenant_context_safety, prepend: true end def cache_key tenant ? "#{tenant}/#{super}" : super end + def ==(other) + super && tenant == other.tenant + end + alias eql? == + + def hash + [ super, tenant ].hash + end + def inspect return super unless tenant @@ -30,14 +41,114 @@ def to_signed_global_id(options = {}) super(options.merge(tenant: tenant)) end - def association(name) - super.tap do |assoc| - if assoc.reflection.polymorphic? || assoc.reflection.klass.tenanted? - ensure_tenant_context_safety + def reload(...) + ensure_tenant_context_safety + + super + end + + def valid?(...) + ensure_tenant_context_safety + + super + end + + def delete + ensure_tenant_context_safety + + super + end + + # #update_column delegates here + def update_columns(...) + ensure_tenant_context_safety + + super + end + + def touch(...) + ensure_tenant_context_safety + + super + end + + # #decrement! delegates here + def increment!(...) + ensure_tenant_context_safety + + super + end + + # A belongs_to writes its foreign key onto this record, so nothing else notices when the + # target came from another tenant's database. + def ensure_belongs_to_tenant_safety + self.class.reflect_on_all_associations(:belongs_to).each do |reflection| + # Avoid creating the Association object for a belongs_to that was never touched + next unless association_cached?(reflection.name) + + target = association(reflection.name).target + + if target && target.class.tenanted? && target.tenant != tenant + raise WrongTenantError, + "#{self.class} model belongs to tenant #{tenant.inspect}, but its " \ + "#{reflection.name} association belongs to tenant #{target.tenant.inspect}" end end end + def ensure_tenant_context_safety + self_tenant = self.tenant + current_tenant = self.class.current_tenant + + if current_tenant.nil? + raise NoTenantError, "Cannot connect to a tenanted database while untenanted (#{self.class})" + elsif self_tenant != current_tenant + raise WrongTenantError, + "#{self.class} model belongs to tenant #{self_tenant.inspect}, " \ + "but current tenant is #{current_tenant.inspect}" + end + end + + # The tenant is an ordinary attribute in serialized snapshots of the record, the same way Job + # and GlobalId serialize the tenant. Rails's only callers of this method are the serializers + # (Marshal 7.1 format and MessagePack); it does not feed the persistence write path. + def attributes_for_database + super.merge!("tenant" => tenant) + end + + def encode_with(coder) + super + coder["tenant"] = tenant + end + + def init_with(coder, &block) + super + + # Psych::Coder does not implement #key?, but exposes the underlying hash as #map + @tenant = coder.map["tenant"] if coder.map.key?("tenant") + + self + end + + def from_json(json, include_root = include_root_in_json) + hash = ActiveSupport::JSON.decode(json) + hash = hash.values.first if include_root + + @tenant = hash.delete("tenant") if hash.key?("tenant") + + self.attributes = hash + self + end + + def marshal_load(state) + has_tenant = state[0].key?("tenant") + tenant_name = state[0].delete("tenant") + + super + + @tenant = tenant_name if has_tenant + end + alias to_gid to_global_id alias to_sgid to_signed_global_id @@ -50,17 +161,10 @@ def init_internals super end - def ensure_tenant_context_safety - self_tenant = self.tenant - current_tenant = self.class.current_tenant - - if current_tenant.nil? - raise NoTenantError, "Cannot connect to a tenanted database while untenanted (#{self.class})" - elsif self_tenant != current_tenant - raise WrongTenantError, - "#{self.class} model belongs to tenant #{self_tenant.inspect}, " \ - "but current tenant is #{current_tenant.inspect}" - end + # Presenting the tenant as one more serializable attribute lets ActiveModel apply the + # `only:` and `except:` options to it, and read it back through the `tenant` reader. + def attribute_names_for_serialization + super + [ "tenant" ] end end diff --git a/lib/active_record/tenanted/untenanted_connection_pool.rb b/lib/active_record/tenanted/untenanted_connection_pool.rb index e306d829..f7e57a7e 100644 --- a/lib/active_record/tenanted/untenanted_connection_pool.rb +++ b/lib/active_record/tenanted/untenanted_connection_pool.rb @@ -32,6 +32,9 @@ def size db_config.max_connections end + def clear_query_cache + end + def lease_connection(...) raise Tenanted::NoTenantError, "Cannot connect to a tenanted database while untenanted (#{@model})." end diff --git a/test/unit/serialization_test.rb b/test/unit/serialization_test.rb new file mode 100644 index 00000000..546688c0 --- /dev/null +++ b/test/unit/serialization_test.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_support/message_pack" + +SERIALIZERS = { + "Marshal" => { + dump: ->(record) { Marshal.dump(record) }, + load: ->(payload) { Marshal.load(payload) }, + }, + "JSON" => { + dump: ->(record) { record.to_json }, + load: ->(payload) { User.new.from_json(payload) }, + }, + "YAML" => { + dump: ->(record) { YAML.dump(record) }, + load: ->(payload) { YAML.unsafe_load(payload) }, + }, + "MessagePack" => { + dump: ->(record) { ActiveSupport::MessagePack::CacheSerializer.dump(record) }, + load: ->(payload) { ActiveSupport::MessagePack::CacheSerializer.load(payload) }, + }, +} + +describe "Serializing tenanted records" do + with_scenario(:primary_db, :primary_record) do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") + end + + SERIALIZERS.each do |format, serializer| + describe format do + let(:dump_in_tenant_context) do + TenantedApplicationRecord.with_tenant("foo") { serializer[:dump].call(User.first) } + end + + let(:dump_outside_tenant_context) do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + TenantedApplicationRecord.without_tenant { serializer[:dump].call(user) } + end + + describe "dumping in a tenanted context" do + test "loading in the same tenant context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("foo") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + + test "loading outside of a tenanted context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + + test "loading in another tenant context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("bar") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + end + + describe "dumping outside of a tenanted context" do + test "loading in the same tenant context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("foo") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + + test "loading outside of a tenanted context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + + test "loading in another tenant context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("bar") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + end + end + end + end +end + +describe "Serializing tenanted records with a loaded association" do + with_scenario(:primary_db, :primary_record) do + setup do + with_migration "20250830152220_create_posts.rb" + User.has_many :posts + Post.belongs_to :user + + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user1@foo.example.org") + Post.create!(title: "Post 1 foo", user: user) + Post.create!(title: "Post 2 foo", user: user) + end + + TenantedApplicationRecord.create_tenant("bar") + end + + # Only these two formats put loaded association targets in their payloads, and so only these + # two restore a record by assigning to an association. + %w[ Marshal MessagePack ].each do |format| + describe format do + let(:serializer) { SERIALIZERS[format] } + + let(:dump_in_tenant_context) do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + user.posts.load + serializer[:dump].call(user) + end + end + + let(:dump_outside_tenant_context) do + user = TenantedApplicationRecord.with_tenant("foo") { User.first.tap { |u| u.posts.load } } + TenantedApplicationRecord.without_tenant { serializer[:dump].call(user) } + end + + describe "dumping in a tenanted context" do + test "loading in the same tenant context round-trips" do + payload = dump_in_tenant_context + + TenantedApplicationRecord.with_tenant("foo") do + loaded = serializer[:load].call(payload) + + assert_equal("foo", loaded.tenant) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], loaded.posts.map(&:title)) + assert_equal([ "foo" ], loaded.posts.map(&:tenant).uniq) + end + end + + test "loading outside of a tenanted context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_predicate(loaded.association(:posts), :loaded?) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], + loaded.association(:posts).target.map(&:title)) + end + + test "loading in another tenant context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("bar") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_predicate(loaded.association(:posts), :loaded?) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], + loaded.association(:posts).target.map(&:title)) + end + end + + describe "dumping outside of a tenanted context" do + test "loading in the same tenant context round-trips" do + payload = dump_outside_tenant_context + + TenantedApplicationRecord.with_tenant("foo") do + loaded = serializer[:load].call(payload) + + assert_equal("foo", loaded.tenant) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], loaded.posts.map(&:title)) + assert_equal([ "foo" ], loaded.posts.map(&:tenant).uniq) + end + end + + test "loading outside of a tenanted context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_predicate(loaded.association(:posts), :loaded?) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], + loaded.association(:posts).target.map(&:title)) + end + + test "loading in another tenant context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("bar") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_predicate(loaded.association(:posts), :loaded?) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], + loaded.association(:posts).target.map(&:title)) + end + end + end + end + end +end + +describe "Serialization options on a tenanted record" do + with_scenario(:primary_db, :primary_record) do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + end + + let(:user) { TenantedApplicationRecord.with_tenant("foo") { User.first } } + + test "the tenant is serialized by default" do + assert_equal("foo", user.serializable_hash["tenant"]) + end + + test "only: excludes the tenant when it is not named" do + assert_not_includes(user.serializable_hash(only: [ :email ]).keys, "tenant") + end + + test "only: includes the tenant when it is named" do + assert_equal("foo", user.serializable_hash(only: [ :email, :tenant ])["tenant"]) + end + + test "except: excludes the tenant when it is named" do + assert_not_includes(user.serializable_hash(except: [ :tenant ]).keys, "tenant") + end + + test "except: includes the tenant when another attribute is named" do + assert_equal("foo", user.serializable_hash(except: [ :email ])["tenant"]) + end + end +end + +# The Marshal, YAML, and JSON hooks are all defined on TenantCommon and so never run for an +# untenanted model. The MessagePack decoder patch is the only one installed globally, on every +# record in a payload, whatever its class. +describe "Serializing an untenanted record that has a tenant attribute" do + with_scenario(:primary_db, :primary_record) do + setup do + Announcement.attribute :tenant, :string + end + + test "MessagePack leaves the attribute alone" do + announcement = Announcement.create!(message: "Announcement 1", tenant: "shared-column-value") + + payload = ActiveSupport::MessagePack::CacheSerializer.dump(announcement) + loaded = ActiveSupport::MessagePack::CacheSerializer.load(payload) + + assert_equal("shared-column-value", loaded.tenant) + end + end +end + +describe "Loading a tenanted record when a callback materializes the attribute set" do + with_scenario(:primary_db, :primary_record) do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + end + + # Only these two formats carry the tenant inside the payload's attributes hash, which is the + # hash the attribute set is built from. + %w[ Marshal MessagePack ].each do |format| + describe format do + let(:serializer) { SERIALIZERS[format] } + + test "the tenant does not become a record attribute" do + User.after_initialize { attribute_names } + + payload = TenantedApplicationRecord.with_tenant("foo") { serializer[:dump].call(User.first) } + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_not_includes(loaded.attribute_names, "tenant") + end + end + end + end +end + +describe "Serializing a tenanted record with no tenant" do + with_scenario(:primary_db, :primary_record) do + setup do + with_schema_cache_dump_file + TenantedApplicationRecord.create_tenant("foo") + end + + SERIALIZERS.each do |format, serializer| + describe format do + test "the loading context does not become the record's tenant" do + user = User.new(email: "user1@example.org") + assert_nil(user.tenant) + + payload = serializer[:dump].call(user) + + loaded = TenantedApplicationRecord.with_tenant("foo") { serializer[:load].call(payload) } + + assert_nil(loaded.tenant) + end + end + end + end +end diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index d77fd243..f249323d 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -493,6 +493,24 @@ end end end + + test "querying a collection proxy carried into another tenant context" do + proxy = TenantedApplicationRecord.with_tenant("foo") { User.first.posts } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + proxy.count + end + end + end + + test "querying a collection proxy carried outside of a tenant context" do + proxy = TenantedApplicationRecord.with_tenant("foo") { User.first.posts } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + proxy.count + end + end end describe "to an untenanted model" do @@ -537,15 +555,16 @@ end end - describe "polymorphic" do + describe "polymorphic to a tenanted model" do setup do + with_migration "20250830152220_create_posts.rb" with_migration "20250830175957_add_announceable_to_users.rb" - User.belongs_to :announceable, polymorphic: true + User.belongs_to :announceable, polymorphic: true, optional: true TenantedApplicationRecord.create_tenant("foo") do # this association doesn't make a lot of sense, but it's just for testing - announcement = Announcement.create!(message: "Announcement 1") - User.create!(email: "user1@foo.example.org", announceable: announcement) + user = User.create!(email: "user1@foo.example.org") + user.update!(announceable: Post.create!(title: "Post 1 foo", user_id: user.id)) end TenantedApplicationRecord.create_tenant("bar") @@ -554,9 +573,9 @@ test "in a tenanted context" do TenantedApplicationRecord.with_tenant("foo") do user = User.first - announcement = user.announceable + post = user.announceable - assert_equal("Announcement 1", announcement.message) + assert_equal("Post 1 foo", post.title) end end @@ -578,10 +597,186 @@ end end end + + describe "polymorphic to an untenanted model" do + setup do + with_migration "20250830175957_add_announceable_to_users.rb" + User.belongs_to :announceable, polymorphic: true + + TenantedApplicationRecord.create_tenant("foo") do + # this association doesn't make a lot of sense, but it's just for testing + announcement = Announcement.create!(message: "Announcement 1") + User.create!(email: "user1@foo.example.org", announceable: announcement) + end + + TenantedApplicationRecord.create_tenant("bar") + end + + test "in a tenanted context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + announcement = user.announceable + + assert_equal("Announcement 1", announcement.message) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + announcement = user.announceable + + assert_equal("Announcement 1", announcement.message) + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + announcement = TenantedApplicationRecord.with_tenant("bar") do + user.announceable + end + + assert_equal("Announcement 1", announcement.message) + end + end end end + describe "association setters" do + for_each_scenario do + describe "to a tenanted model" do + setup do + with_migration "20250830152220_create_posts.rb" + Post.belongs_to :user + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user1@bar.example.org") + Post.create!(title: "Post 1 bar", user: user) + end + end + + let(:foo_user) { TenantedApplicationRecord.with_tenant("foo") { User.first } } + let(:bar_post) { TenantedApplicationRecord.with_tenant("bar") { Post.first } } + + describe "belongs_to" do + test "assigning a record from another tenant does not raise" do + user = foo_user + + TenantedApplicationRecord.with_tenant("bar") do + post = Post.first + + assert_nothing_raised { post.user = user } + end + end + + test "saving with validate: false after assigning a record from another tenant raises" do + user = foo_user + + TenantedApplicationRecord.with_tenant("bar") do + post = Post.first + post.user = user + + assert_raises(ActiveRecord::Tenanted::WrongTenantError) { post.save(validate: false) } + end + end + + test "saving with a cached association from the same tenant does not raise" do + TenantedApplicationRecord.with_tenant("bar") do + post = Post.first + post.user = User.first + + assert_nothing_raised { post.update!(title: "Post 1 bar, edited") } + end + end + + test "saving with an association that was never loaded does not raise" do + TenantedApplicationRecord.with_tenant("bar") do + post = Post.first + + assert_nothing_raised { post.update!(title: "Post 1 bar, edited") } + end + end + end + + describe "has_many" do + setup { User.has_many :posts } + + test "assigning a record from another tenant does not raise" do + post = bar_post + + TenantedApplicationRecord.with_tenant("foo") do + user = User.new(email: "user2@foo.example.org") + + assert_nothing_raised { user.posts = [ post ] } + end + end + + test "saving after assigning a record from another tenant raises" do + post = bar_post + + TenantedApplicationRecord.with_tenant("foo") do + user = User.new(email: "user2@foo.example.org") + user.posts = [ post ] + + assert_raises(ActiveRecord::Tenanted::WrongTenantError) { user.save } + end + end + end + + describe "has_one" do + setup { User.has_one :post } + + test "assigning a record from another tenant does not raise" do + post = bar_post + + TenantedApplicationRecord.with_tenant("foo") do + user = User.new(email: "user2@foo.example.org") + + assert_nothing_raised { user.post = post } + end + end + + test "saving after assigning a record from another tenant raises" do + post = bar_post + + TenantedApplicationRecord.with_tenant("foo") do + user = User.new(email: "user2@foo.example.org") + user.post = post + + assert_raises(ActiveRecord::Tenanted::WrongTenantError) { user.save } + end + end + end + end + + describe "to an untenanted model" do + setup do + with_migration "20250830170325_add_announcement_to_users.rb" + User.belongs_to :announcement + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org", + announcement: Announcement.create!(message: "Announcement 1")) + end + end + + test "saving after assigning an untenanted record does not raise" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + user.announcement = Announcement.create!(message: "Announcement 2") + + assert_nothing_raised { user.save! } + end + end + end + end + end + describe ".without_tenant" do for_each_scenario do setup do @@ -1215,6 +1410,53 @@ end end + describe "#==" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + let(:foo_user) { TenantedApplicationRecord.with_tenant("foo") { User.first } } + let(:bar_user) { TenantedApplicationRecord.with_tenant("bar") { User.first } } + + test "records from the same tenant with the same id are equal" do + other = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_equal(foo_user, other) + end + + test "records from the same tenant with the same id have the same hash" do + other = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_equal(foo_user.hash, other.hash) + end + + test "records from different tenants with the same id are not equal" do + assert_equal(foo_user.id, bar_user.id) + + assert_not_equal(foo_user, bar_user) + end + + test "records from different tenants with the same id are not eql?" do + assert_equal(foo_user.id, bar_user.id) + + assert_not(foo_user.eql?(bar_user)) + end + + test "records from different tenants with the same id have different hashes" do + assert_equal(foo_user.id, bar_user.id) + + assert_not_equal(foo_user.hash, bar_user.hash) + end + end + end + describe "#cache_key" do for_each_scenario do describe "created in untenanted context" do @@ -1250,6 +1492,418 @@ end end + describe "#reload" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_equal("user1@foo.example.org", user.reload.email) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.reload + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.reload + end + end + end + end + end + + describe "#destroy" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.destroy + + assert_equal(0, User.count) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.destroy + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.destroy + end + end + end + end + end + + describe "#delete" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.delete + + assert_equal(0, User.count) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.delete + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.delete + end + end + end + end + end + + describe "#update_column" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.update_column(:email, "edited@foo.example.org") + + assert_equal("edited@foo.example.org", User.first.email) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.update_column(:email, "edited@foo.example.org") + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.update_column(:email, "edited@foo.example.org") + end + end + end + end + end + + describe "#update_columns" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.update_columns(email: "edited@foo.example.org") + + assert_equal("edited@foo.example.org", User.first.email) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.update_columns(email: "edited@foo.example.org") + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.update_columns(email: "edited@foo.example.org") + end + end + end + end + end + + describe "#touch" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + was = user.updated_at + + user.touch + + assert_operator(user.updated_at, :>, was) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.touch + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.touch + end + end + end + end + end + + describe "#increment!" do + for_each_scenario do + setup do + with_migration "20250213005959_add_age_to_users.rb" + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org", age: 10) + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org", age: 20) + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.increment!(:age) + + assert_equal(11, User.first.age) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.increment!(:age) + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.increment!(:age) + end + end + end + end + end + + describe "#decrement!" do + for_each_scenario do + setup do + with_migration "20250213005959_add_age_to_users.rb" + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org", age: 10) + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org", age: 20) + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.decrement!(:age) + + assert_equal(9, User.first.age) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.decrement!(:age) + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.decrement!(:age) + end + end + end + end + end + + describe "#save with validate: false" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + user.email = "edited@foo.example.org" + + assert(user.save(validate: false)) + assert_equal("edited@foo.example.org", User.first.email) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.save(validate: false) + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.save(validate: false) + end + end + end + end + end + + describe "#valid?" do + for_each_scenario do + setup do + User.validates_uniqueness_of :email + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + user.email = "edited@foo.example.org" + + assert_predicate(user, :valid?) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.valid? + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.valid? + end + end + end + end + end + describe "#inspect" do for_each_scenario do describe "created in untenanted context" do diff --git a/test/unit/untenanted_connection_pool_test.rb b/test/unit/untenanted_connection_pool_test.rb index 94c9b170..273a0cec 100644 --- a/test/unit/untenanted_connection_pool_test.rb +++ b/test/unit/untenanted_connection_pool_test.rb @@ -24,5 +24,9 @@ def config.max_connections; 42; end assert_equal 42, subject.size end + + test "clear_query_cache does nothing" do + assert_nil subject.clear_query_cache + end end end