diff --git a/_i18n/en/_posts/2024-04-15-default-scope.md b/_i18n/en/_posts/2024-04-15-default-scope.md new file mode 100644 index 0000000..c1ef813 --- /dev/null +++ b/_i18n/en/_posts/2024-04-15-default-scope.md @@ -0,0 +1,429 @@ +--- +layout: post +photo: /images/default-scope/default-scope-header +title: Rails Default Scope Overview +description: What you don't know about Default Scope? +headline: Premature optimization is the root of all evil. +categories: [programming] +tags: [Ruby, Ruby on Rails] +imagefeature: default-scope/og_image.png +lang: en +--- + +Do a quick online search for "Rails Default Scope" and you will get a ton of articles: why you should not use default scope at all, why default scope is the root of all evil, how to remove default scope from your project. These articles often have a strong negative opinion about default scope. But is default scope really that bad? The default scope discussion has been going on since at least 2015, almost a decade of Rails development, and people are still talking about it. + +Let's face it: in most cases, these articles make good points about why default scopes can be risky. But does that mean you should never use them? If default scopes are so problematic, why does Rails still have them after all these years? Could there be specific scenarios where default scopes are actually beneficial and safe to use? In this article, I'll break down default scopes, explain how they work, and explore whether they have any place in modern Rails projects. Let's dive in and find out together! + +{% include toc.html %} + +## What is the `default_scope`? + +Based on api.rubyonrails.org documentation for Rails 7.1 `default_scope` is a macro in a model to set a default scope for all operations on the model. So, we can narrow down all operations on the model to a specific order or query. + +## How to create a default scope? + +```ruby +class Article < ActiveRecord::Base + default_scope { where(published: true) } +end +``` + +There is a second way to declare `default_scope`: + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) } +end +``` + +The default scope defined this way will limit the `.all` query to published articles only. + +```ruby +Article.all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true +``` + +Since the default scope has the following definition: + +```ruby +def default_scope(scope = nil, all_queries: nil, &block) + scope = block if block_given? + + if scope.is_a?(Relation) || !scope.respond_to?(:call) + + # [...] +end +``` + +We can have a little fun with creating a default scope. First, we can create `Proc` object and provide it as an argument to `default_scope` in two ways: + +```ruby +class Article < ActiveRecord::Base + published_articles = -> { where(published: true) } + + default_scope(all_queries: true, &published_articles) + default_scope(published_articles, all_queries: true) +end +``` + +It's possible because when block is provided it is assigned as scope inside `default_scope` definition. The second trick we can do is to prepare a class that has a `call` method. It's the only condition we need to fulfill to be able to create our `default_scope`. + +```ruby +class PublishedScope + def initialize(context) + @context = context + end + + def call + context.where(published: true) + end + + private + + attr_reader :context +end + + +class Article < ActiveRecord::Base + default_scope(PublishedScope.new(self), all_queries: true) +end +``` + +This allows us to extract scope logic into a separate class/context. + +One more note at the end of this section. If you are wondering if you can have, for example, date/time calculations in the scope, the answer is yes. Since we have `Proc` in a block that is calculated each time the default scope is run, we don't have to worry about the date/time freezing. + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where('created_at > ?', Time.current) } +end + +Article.all +# SELECT "articles".* FROM "articles" WHERE (created_at > '2024-04-29 10:16:38.292367') + +Article.all +# SELECT "articles".* FROM "articles" WHERE (created_at > '2024-04-29 10:18:49.980174') +``` + +## Default scope vs. creating/building of new object + +When we use the default scope, we need to remember that the `default_scope` is also applied when we create/build a record. So if you have default scope: + +```ruby +class Article < ActiveRecord::Base + default_scope { where(published: true) } +end +``` + +The `published` attribute is set to `true` for all built and created records: + +```ruby +Article.new +# => #
+``` + +Depending on your needs, this may or may not be expected behavior. In the case of articles, where usual flow is first a draft and then a published article for all readers, it can be problematic. So while you want to make sure you don't accidentally list unpublished articles, you now create published articles by default. + +So the important thing to remember is that **`default_scope` will always affect your model initialization and creation**. Of course, there is a way to override the default value during initialization, but this is one more thing to remember: + +```ruby +Article.new(published: false) +# => #
+``` + +## Default scope vs. object update + +**By default, `default_scope` is not applied when a record is updated**. + +```ruby +article = Article.last +# => #
+ +article.update(title: 'Default scope - user manual') +# => #
+``` + +If you want to apply a `default_scope` when updating or deleting a record, add `all_queries: true` to your `default_scope` declaration: + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) }, all_queries: true +end +``` + +then you will get + +```ruby +article = Article.last +# => #
+ +article.update(title: 'Default scope - user manual') +# => #
+``` + +If you use `all_queries: true`, remember that the default scope is applied to **all queries**. So this is what you get when you delete the object: + +```ruby +Article.find(1).destroy +# DELETE FROM "articles" WHERE "articles"."id" = ? AND "articles"."published" = ? [["id", 1], ["published", true]] +``` + +Only published records are deleted. This behavior may surprise you when you try to remove a record that is not published. + +One more thing to add to this section. I told you that by default `default_scope` is not used for updates. This is only true for updating one object. **In case of `update_all` the default scope will be used**, even if you didn't set `all_queries: true`. If you want to do all articles published by: + +```ruby +Article.all_update(published: true) +# UPDATE "articles" SET "published" = ? WHERE "articles"."published" = ? [["published", true], ["published", true]] +``` + +You will still have objects in the database with `published: false` because of the narrowing of the query during `update_all`. The same situation will happen for `destroy_all` - default scope will narrow the query. + +## Multiple default scopes + +You can use multiple default scopes in a model, they will combine + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) } + default_scope -> { where(archived: true) } +end +``` + +and you will get: + +```ruby +Article.all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true AND "articles"."archived" = true +``` + +In this case, both attributes are set during object initialization. + +```ruby +Article.new +# => #
+``` + +If you want to check all the default scopes on your model, you can use: + +```ruby +Article.default_scope + +# => +# [#>, +# #>] +``` + +You will see all the places in the code where you have default scope declarations for your `Article` model. + +## Default scope vs. inheritance + +In the case of inheritance and module includes, where the parent or module defines one `default_scope` and the child or including class defines a second, these default scopes will be linked together as they are when default scopes are in the same model. + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) } +end + +class ArchivedArticle < Article + default_scope -> { where(archived: true) } +end +``` + +So our `ArchivedArticle` will have two scopes, being `published` and `archived` at the same time: + +```ruby +ArchivedArticle.all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true AND "articles"."archived" = true +``` + +I want to mention one thing here. The idea of adding a default scope for the `Article` class as a general class for articles is not a good idea in most cases, because we don't expect this behavior in the `Article` class. For a subset of articles like `ArchivedArticle`, where the name of the class already tells us about a specific type of article, the default scope can be useful. + +## Default scope vs. association + +Let's say we have two models: `Article` which can be created by `Author`. Each article can be created by an author, and an author can have multiple articles. + +```ruby +class Author < ActiveRecord::Base + has_many :atricles, dependent: :destroy +end + +class Article < ActiveRecord::Base + belongs_to :author + default_scope -> { where(published: true) } +end +``` + +If we try to select all articles for a specific author, the default scope will apply to our query and we will get only published articles - **`default_scope` will apply to model associations**. + +```ruby +author.articles +# SELECT "atricles".* FROM "atricles" WHERE "atricles"."published" = ? AND "atricles"."author_id" = ? [["published", true], ["author_id", 1]] +``` + +Let's say we want to remove author and all of his articles. Without default scope we could just do `author.destroy`, but if the articles have default scope the expected behavior will be different than what actually happens. Calling `author.destroy` will delete all articles that are `published`, but it won't delete articles that are `unpublished`. Therefore, the database will throw a foreign key violation because it contains records that reference the author we want to remove. It's important to keep this in mind. + +## Default scope vs. overriding default scope value + +Let's say our default scope on `Article` is the order of the items: + +```ruby +class Article < ActiveRecord::Base + default_scope -> { order(created_at: :desc) } +end +``` + +and now instead of ordering by `created_at` you want to order by `updated_at`. The `Article.order(updated_at: :desc)` will not do what you expect. In this case we will get similar behavior as in the case of inheritance - scopes will accumulate. So you will see: + +```ruby +Arcicle.order(updated_at: :desc).limit(10) +# SELECT "articles".* FROM "articles" ORDER BY "articles"."created_at" DESC, "articles"."updated_at" DESC LIMIT 10 +``` + +It sorts articles by both `created_at` and `updated_at`, the default scope is not overridden, you need to use `unscoped` to explicitly disable the default scope. + +```ruby +Article.unscoped.order(updated_at: :desc).limit(10) +# SELECT "articles".* FROM "articles" ORDER BY "articles"."updated_at" DESC LIMIT 10 +``` + +But be aware that `unscoped` can be tricky. See below. + +## Default scope vs. `unscoped` + +Unscope allows us to remove unwanted scopes that are already defined on a chain of scopes. This means that if you only want to remove one scope, you can do that, but you can also remove all of them at once. + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) } + default_scope -> { where(archived: true) } +end +``` + +So if you use `unscoped` you will remove all scopes. + +```ruby +Articles.all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true AND "articles"."archived" = true + +Articles.unscope.all +# SELECT "articles".* FROM "articles" +``` + +But there is a way to remove only part of our default scope. In this case, you need to pass a specific argument to the `unscope` method. + +```ruby +Article.unscope(where: :archived).all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true + +Article.unscope(where: :published).all +# SELECT "articles".* FROM "articles" WHERE "articles"."archived" = true +``` + +We also need to remember that the order of scopes and unscopes is important. If you unscope first and then add a new scope, you will clean up the scope and provide a new one: + +```ruby +Article.uncoped.where(title: 'Default scope overview') +# SELECT "articles".* FROM "articles" WHERE "articles"."title" = 'Default scope overview' +``` + +but if you change the order of these methods, you will remove all scopes, including this new `where`: + +```ruby +Article.where(title: 'Default scope overview').uncoped +# SELECT "articles".* FROM "articles" +``` + +Interesting case we can get with unscope while using asociations. + +```ruby +class Author < ActiveRecord::Base + has_many :atricles +end + +class Article < ActiveRecord::Base + belongs_to :author + default_scope -> { where(published: true) } +end +``` + +As we discussed earlier, when we use articles for specific authors, those articles are limited to the default scope: + +```ruby +Author.first.articles +# SELECT "atricles".* FROM "atricles" WHERE "atricles"."published" = ? AND "atricles"."author_id" = ? [["published", true], ["author_id", 1]] +``` + +but when we try to unscope articles, we no longer have the author condition. + +```ruby +Autor.first.atricles.unscoped +# SELECT "articles".* FROM articles +``` + +So it's important to remember that **`unscoped` removes ALL scopes that might normally apply to your select, including (but not limited to) associations**. + +To do this unscope in a correct way we need to unscope only the `published` scope: + +```ruby +Author.first.articles.unscope(where: :published) +# SELECT "atricles".* FROM "atricles" WHERE "atricles"."author_id" = ? [["author_id", 1]] +``` + +## Several ways to override the default scope + +Let's define our class again, as shown below: + +```ruby + class Article < ActiveRecord::Base + default_scope -> { where(status: :published) } + scope :archvied, -> { where(status: :archived)} + end +``` + +We have several options to get what we want. Get only archived articles, whether they are published or not. + +We can use `unscoped` and then the `archived` scope: + +```ruby +Article.unscoped.archvied +# SELECT "articles".* FROM articles WHERE "articles"."status" = 'archived' +``` + +We can use `unscope` specific scope: + +```ruby +Article.unscope(where: :state).archvied +# SELECT "articles".* FROM articles WHERE "articles"."status" = 'archived' +``` + +We can use `rewhere`: + +```ruby +Article.rewhere(state: :archived) +# SELECT "articles".* FROM articles WHERE "articles"."status" = 'archived' +``` + +If your scope uses order, you can use the `reorder` method. + +## Summary + +- If you don't understand how default scope works, it can bring you a lot of trouble: long debugging time, unexpected behavior, strange problems, lack of readability, and more. +- Default scope can get really complicated, especially when used with associations or inheritance. +- The `default_scope` behaves similarly to the ActsAsParanoid gem, so in case of this gem I also suggest caution. Think twice before using it. +- We can also think of `defaul_scope` as being similar to global state or singleton. We have to be sure what we are doing. These are useful tools, but used without enough caution can be dangerous ;) +- The `default_scope` is in my opinion a tool that should be used in very specific and rare cases, but I can't agree that it is the root of all evil ;) +- In my opinion, the biggest problem with `default_scope` is using it implicitly - hidden in code. When we do that, all the problems with understanding the logic, debugging, and strange behavior start. So I think it is more of a communication problem. Use `default_scope` explicitly. + +## Sources + +- Why is using the rails default_scope often recommend against? +- Using Default Scope and Unscoped in Rails +- How to Carefully Remove a Default Scope in Rails +- Beware of using default scope +- default_scope - Ruby on Rails documentation diff --git a/_i18n/pl/_posts/2024-04-15-default-scope.md b/_i18n/pl/_posts/2024-04-15-default-scope.md new file mode 100644 index 0000000..6492fa4 --- /dev/null +++ b/_i18n/pl/_posts/2024-04-15-default-scope.md @@ -0,0 +1,429 @@ +--- +layout: post +photo: /images/default-scope/default-scope-header +title: Przegląd default scope w Rails +description: Czego nie wiesz o Default Scope w Railsach? +headline: Premature optimization is the root of all evil. +categories: [programowanie] +tags: [Ruby, Ruby on Rails] +imagefeature: default-scope/og_image.png +lang: pl +--- + +Jeśli poszukasz w Internecie frazy "Rails Default Scope", znajdziesz ogrom artykułów: dlaczego nie warto używać default scope, dlaczego default scope to źródło wielu problemów i jak usunąć default scope z projektu. Te artykuły często wyrażają silna negatywną opinie na temat default scope. Ale czy default scope jest naprawdę tak zły? Dyskusja na temat default scope toczy się przynajmniej od 2015 roku, prawie dziesięć lat, a ludzie nadal na ten temat rozmawiają. Dziś ja dołożę do tego wątku swoją cegiełkę. + +Bądźmy szczerzy: w większości przypadków artykuły te trafnie określają powody, dla których zastosowanie default scope może być ryzykowne. Jednak czy to oznacza, że nie powinniśmy w ogóle stosować default scope? Skoro default scope jest tak problematyczny, to czy po tylu latach dalej byłby częścią Railsów? A może istnieją jakieś scenariusze, gdzie warto użyć default scope? W tym artykule chciałabym dokładnie wyjaśnić specyfikę działania default scope i sprawdzić czy jest miejsce dla default scope w nowoczesnych projektach opartych na Ruby on Rails. Zaczynajmy! + +{% include toc.html %} + +## Czym jest `default_scope`? + +Na podstawie dokumentacji api.rubyonrails.org dla Rails 7.1 `default_scope` to marko w modelu ustawiające domyślny zakres dla wszystkich operacji na modelu. Jest to więc zawężenie wyników wszystkich operacji na modelu do określonego zapytania, warunku lub kolejności elementów. + +## Jak stworzyć default scope? + +```ruby +class Article < ActiveRecord::Base + default_scope { where(published: true) } +end +``` + +Istnieje też inny sposób deklaracji `default_scope`: + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) } +end +``` + +Default scope określa ograniczenia na metodę `.all` i w naszym przypadku wyświetla tylko publiczne artykuły. + +```ruby +Article.all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true +``` + +Ze względu na poniższa definicję default scope: + +```ruby +def default_scope(scope = nil, all_queries: nil, &block) + scope = block if block_given? + + if scope.is_a?(Relation) || !scope.respond_to?(:call) + + # [...] +end +``` + +Możemy trochę pobawić się tworzeniem default scope. Na początek, możemy stworzyć obiekt typu `Proc` i przekazać go jako argument do `default_scope` na dwa różne sposoby: + +```ruby +class Article < ActiveRecord::Base + published_articles = -> { where(published: true) } + + default_scope(all_queries: true, &published_articles) + default_scope(published_articles, all_queries: true) +end +``` + +Jest to możliwe ponieważ podając blok kodu w definicji `default_scope` zostanie on przypisany do zmiennej `scope` wewnątrz metody. Drugi trik dotyczy przygotowania odpowiedniej klasy, która posiada metodę instancji `call`. Jest to jedyny warunek jaki musi spełniać podany jako argument obiekt by stworzyć `default_scope`. + +```ruby +class PublishedScope + def initialize(context) + @context = context + end + + def call + context.where(published: true) + end + + private + + attr_reader :context +end + + +class Article < ActiveRecord::Base + default_scope(PublishedScope.new(self), all_queries: true) +end +``` + +Ta możliwość pozwala nam wyekstrahować logikę naszego zakresu do zewnętrznej klasy/kontekstu. + +Na koniec tej części jeszcze jedna uwaga. Jeżeli zastanawiasz się, czy warunek zawierający na przykład odwołanie do bieżącej daty lub godziny będzie odpowiednio wyliczony za każdym razem, gdy wywołany zostanie twój default scope to odpowiedź brzmi tak. Ze względu na to że `Proc` jest blokiem kodu, który jest obliczany każdorazowo przy uruchomieniu nie musimy się martwić _zamrożeniem_ daty lub czasu wewnątrz default scope. + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where('created_at > ?', Time.current) } +end + +Article.all +# SELECT "articles".* FROM "articles" WHERE (created_at > '2024-04-29 10:16:38.292367') + +Article.all +# SELECT "articles".* FROM "articles" WHERE (created_at > '2024-04-29 10:18:49.980174') +``` + +## Default scope a tworzenie nowego obiektu + +Gdy zaczynamy używać default scope musimy pamiętać, że `default_scope` jest używany również podczas tworzenia obiektu. Jeżeli mamy: + +```ruby +class Article < ActiveRecord::Base + default_scope { where(published: true) } +end +``` + +Atrybut `published` jest ustawiony na `true` dla każdego zainicjowanego i stworzonego rekordu: + +```ruby +Article.new +# => #
+``` + +W zależności od twoich potrzeb, może to być oczekiwane lub nie oczekiwane zachowanie. W przypadku tworzenia artykułu raczej na początku chcielibyśmy zapisać artykuł jako szkic, a dopiero po jego doszlifowaniu opublikować go. Więc powyższe zachowanie może być dla nas problematyczne. Z jednej strony używając default scope możemy chcieć się zabezpieczyć przed pokazywaniem nieopublikowanych artykułów, z drugiej z automatu tworzymy publiczny artykuł. + +Istotną sprawą jest pamiętanie że **`default_scope` zawsze oddziałuje na inicjalizacje i tworzenie obiektu**. Oczywiście jest możliwość nadpisania domyślnego zachowania, ale jest to dodatkowa rzecz, o której trzeba pamiętać podczas implementacji: + +```ruby +Article.new(published: false) +# => #
+``` + +## Default scope a aktualizacja obiektu + +**Domyślnie `default_scope` nie jest uruchamiany podczas aktualizacji obiektu**. + +```ruby +article = Article.last +# => #
+ +article.update(title: 'Default scope - user manual') +# => #
+``` + +Jeżeli chcesz by `default_scope` był wywoływany podczas aktualizacji lub usuwania obiektu dodaj `all_queries: true` to swojej deklaracji `default_scope`: + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) }, all_queries: true +end +``` + +otrzymasz wtedy + +```ruby +article = Article.last +# => #
+ +article.update(title: 'Default scope - user manual') +# => #
+``` + +Pamiętaj jednak, że jeżeli użyjesz `all_queries: true`, default scope będzie wywoływany do **wszystkich zapytań**. Oto co dostaniesz w przypadku usuwania obiektu: + +```ruby +Article.find(1).destroy +# DELETE FROM "articles" WHERE "articles"."id" = ? AND "articles"."published" = ? [["id", 1], ["published", true]] +``` + +Tylko opublikowane rekordy będą usuwane. To zachowanie może cię zaskoczyć, kiedy będziesz chcieć usunąć nieopublikowane artykuły. + +Jeszcze jedna ważna rzecz dotycząca tej części. Powiedziałam Ci, że domyślnie `default_scope` nie jest używany podczas aktualizacji obiektu. Jest to prawda tylko w przypadku metody `update`. Natomiast **w przypadku `update_all` default scope będzie użyty**, nawet jeśli nie ustawisz `all_queries: true`. Przykładowo, jeżeli chcesz opublikować wszystkie artykuły za pomocą: + +```ruby +Article.all_update(published: true) +# UPDATE "articles" SET "published" = ? WHERE "articles"."published" = ? [["published", true], ["published", true]] +``` + +nadal będziesz mieć w bazie danych obiekty, które mają atrybut `published: false`, ponieważ `update_all` zawęził twoje zapytanie tylko do artykułów już opublikowanych. Ta sama sytuacja zachodzi w przypadku `destroy_all` - default scope zawęzi zapytanie. + +## Wiele deklaracji default scope + +W swoim modelu możesz mieć wiele deklaracji default scope. Wszystkie one połączą się w czasie wywołania + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) } + default_scope -> { where(archived: true) } +end +``` + +więc otrzymasz: + +```ruby +Article.all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true AND "articles"."archived" = true +``` + +W tym przypadku również podczas inicjalizacji obiektu oba domyślne zakresy będą uwzględnione. + +```ruby +Article.new +# => #
+``` + +Jeżeli chcesz sprawdzić jakie typy default scope zawiera twój model możesz użyć: + +```ruby +Article.default_scope + +# => +# [#>, +# #>] +``` + +Dzięki temu zobaczysz w jakich miejscach są zadeklarowane wszystkie twoje domyślne zakresy dla modelu `Article`. + +## Default scope a dziedziczenie + +W przypadku dziedziczenia i dołączania modułów, gdy w klasie po której dziedziczymy oraz w klasie dziedziczącej są zdefiniowane `default_scope`, to ich funkcjonalność łączy się tak, jak w przypadku wielu deklaracji default scope w jednej klasie. + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) } +end + +class ArchivedArticle < Article + default_scope -> { where(archived: true) } +end +``` + +Nasz model `ArchivedArticle` będzie posiadać dwa zakresy: `published` i `archived`: + +```ruby +ArchivedArticle.all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true AND "articles"."archived" = true +``` + +Jedna ważna rzecz. Pomysł dodania default scope dla klasy `Article`, jako klasy ogólnej dla wszystkich typów artykułów, nie jest zbyt dobrym pomysłem - po prostu nie spodziewamy się tam żadnego zawężenia zakresu. W przypadku jednak podtypu artykułów, takiego typu jak klasa `ArchivedArticle`, gdzie nazwa mówi sama za siebie, default scope może być całkiem użyteczny. + +## Default scope a asocjacje + +Załóżmy, że mamy dwa modele: `Article` i `Author`. Każdy artykuł ma jednego autora, a każdy autor może stworzyć wiele artykułów. + +```ruby +class Author < ActiveRecord::Base + has_many :atricles, dependent: :destroy +end + +class Article < ActiveRecord::Base + belongs_to :author + default_scope -> { where(published: true) } +end +``` + +Jeżeli zechcemy wybrać wszystkie artykuły danego autora default scope spowoduje, że zobaczymy tylko te publiczne artykuły. Na tej podstawie widzimy, że **`default_scope` zostanie użyty przy korzystaniu z asocjacji w modelu**. + +```ruby +author.articles +# SELECT "atricles".* FROM "atricles" WHERE "atricles"."published" = ? AND "atricles"."author_id" = ? [["published", true], ["author_id", 1]] +``` + +Załóżmy teraz, że chcemy usunąć autora wraz z wszystkimi jego artykułami. W przypadku braku domyślnego zakresu w modelu moglibyśmy po prostu wywołać `author.destroy`, ale gdy artykuł ma default scope oczekiwane zachowanie będzie inne niż to co naprawdę zostanie wykonane. Wywołując `author.destroy` zaczniemy usuwanie tylko artykułów, które są `published`, ale artykuły nieopublikowane nie zostaną usunięte. To spowoduje wyjątek po stronie bazy danych dotyczący naruszenia klucza obcego. W przeciwnym wypadku w bazie danych zostałyby rekordy odwołujące się do nieistniejącego autora. + +## Default scope a nadpisanie domyślnej wartości zakresu + +Powiedzmy, że masz default scope na modelu `Article`, który zwraca rekordy w odpowiedniej kolejności: + +```ruby +class Article < ActiveRecord::Base + default_scope -> { order(created_at: :desc) } +end +``` + +i chcesz zmienić kolejność elementów z sortowania po `created_at` na `updated_at`. W takiej sytuacji `Article.order(updated_at: :desc)` nie zrobi tego, co oczekujesz. Zamiast kolejności względem pola `updated_at` otrzymasz podobnie jak w przypadku dziedziczenia połączenie warunków. + +```ruby +Arcicle.order(updated_at: :desc).limit(10) +# SELECT "articles".* FROM "articles" ORDER BY "articles"."created_at" DESC, "articles"."updated_at" DESC LIMIT 10 +``` + +Artykuły zostaną posortowane względem obu pól: `created_at` i `updated_at`. Default scope nie zostanie nadpisany. Musisz użyć metody `unscoped`, by pozbyć się niechcianego default scope. + +```ruby +Article.unscoped.order(updated_at: :desc).limit(10) +# SELECT "articles".* FROM "articles" ORDER BY "articles"."updated_at" DESC LIMIT 10 +``` + +Pamiętaj jednak, że `unscoped` może być zdradliwy. Spójrz poniżej. + +## Default scope a `unscoped` + +`Unscope` pozwala nam usunąć niechciane zakresy, które są już zdefiniowane w modelu. To znaczy, że możemy usunąć wybrany scope, ale też możemy usunąć wszystkie zakresy. + +```ruby +class Article < ActiveRecord::Base + default_scope -> { where(published: true) } + default_scope -> { where(archived: true) } +end +``` + +Jeżeli użyjesze metody `unscoped` usuniesz wszystkie zakresy. + +```ruby +Articles.all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true AND "articles"."archived" = true + +Articles.unscope.all +# SELECT "articles".* FROM "articles" +``` + +Jeżeli chcesz usunąć tylko jeden z nich, to możesz zrobić to za pomocą metody `unscope` podając dodatkowo wybrany warunek. + +```ruby +Article.unscope(where: :archived).all +# SELECT "articles".* FROM "articles" WHERE "articles"."published" = true + +Article.unscope(where: :published).all +# SELECT "articles".* FROM "articles" WHERE "articles"."archived" = true +``` + +Trzeba także pamiętać, że kolejność wywołania metod ma znaczenie. Jeżeli najpierw użyjemy metody `unscoped` a później dodamy nowy warunek, to default scope zostanie usunięty, ale nowy warunek będzie uwzględniony w zapytaniu: + +```ruby +Article.uncoped.where(title: 'Default scope overview') +# SELECT "articles".* FROM "articles" WHERE "articles"."title" = 'Default scope overview' +``` + +Natomiast, gdy zmienimy kolejność tych metod, to usuniemy wszystkie warunki i default scope i nowy warunek `where`: + +```ruby +Article.where(title: 'Default scope overview').uncoped +# SELECT "articles".* FROM "articles" +``` + +Interesujący przypadek z `unscoped` dostaniemy dla asocjacji. + +```ruby +class Author < ActiveRecord::Base + has_many :atricles +end + +class Article < ActiveRecord::Base + belongs_to :author + default_scope -> { where(published: true) } +end +``` + +Tak jak już wspominałam wcześniej, gdy pytamy o artykuły konkretnego autora, wyniki zostaną ograniczone do tych spełniających default scope: + +```ruby +Author.first.articles +# SELECT "atricles".* FROM "atricles" WHERE "atricles"."published" = ? AND "atricles"."author_id" = ? [["published", true], ["author_id", 1]] +``` + +ale gdy użyjemy `unscoped`, nawet warunek dotyczący autora zostanie usunięty. + +```ruby +Autor.first.atricles.unscoped +# SELECT "articles".* FROM articles +``` + +Warto zapamiętać, że **`unscoped` usuwa WSZYSTKIE zakresy nawet te związane z asocjacjami**. + +By pozbyć się niechcianego default scope musimy użyć metody `unscope` i wybrać tylko zakres `published`: + +```ruby +Author.first.articles.unscope(where: :published) +# SELECT "atricles".* FROM "atricles" WHERE "atricles"."author_id" = ? [["author_id", 1]] +``` + +## Sposoby na nadpisanie default scope + +Zdefiniujmy jeszcze raz naszą klasę: + +```ruby + class Article < ActiveRecord::Base + default_scope -> { where(status: :published) } + scope :archvied, -> { where(status: :archived)} + end +``` + +Istnieje kilka możliwości by dostać tylko artykuły zarchiwizowane niezależnie od tego czy były publiczne czy nie. + +Możemy użyć `unscoped` a później metodę `archived`: + +```ruby +Article.unscoped.archvied +# SELECT "articles".* FROM articles WHERE "articles"."status" = 'archived' +``` + +Możemy użyć metody `unscope` i wybrać konkretny zakres, który chcemy pominąć: + +```ruby +Article.unscope(where: :state).archvied +# SELECT "articles".* FROM articles WHERE "articles"."status" = 'archived' +``` + +Dostępna jest też metoda `rewhere`: + +```ruby +Article.rewhere(state: :archived) +# SELECT "articles".* FROM articles WHERE "articles"."status" = 'archived' +``` + +W przypadku default scope opartego na sortowaniu możemy użyć metody `reorder`. + +## Podsumowanie + +- Jeśli nie rozumiesz, jak default scope działa, może Ci to przysporzyć wiele problemów: długi czas debugowania, dziwne lub niespodziewane zachowanie aplikacji, czy też nieczytelny kod +- Default scope może stać się dość skomplikowany zwłaszcza w przypadku dziedziczenia czy relacji. +- `default_scope` zachowuje się podobnie do gemu ActsAsParanoid, w obu przypadkach warto zachować ostrożność i pomyśleć dwa razy przed podjęciem decyzji o użyciu tych rozwiązań. +- Możemy myśleć o `defaul_scope` jako o czymś podobnym do globalnego stanu lub wzorca projektowego singleton. Musimy wiedzieć, co robimy, te narzędzia mogą być zarówno użyteczne jak i niebezpieczne ;) +- Moim zdaniem `default_scope` jest narzędziem, które warto używać w bardzo określonych przypadkach, jednak nie mogę się zgodzić, że to źródło wszelkiego zła ;) +- Największym problemem z `default_scope` jest użycie go niejawnie - ukrywając go gdzieś w kodzie. W takim przypadku będziemy mieć problemy z zrozumieniem logiki, debugowaniem i dziwnym zachowaniem. Są to jednak problemy z komunikacją (programista - kod - programista). Dlatego warto używać `default_scope` jawnie, jak w przypadku klasy `ArchivedArticle`. + +## Źródła + +- Why is using the rails default_scope often recommend against? - EN +- Using Default Scope and Unscoped in Rails - EN +- How to Carefully Remove a Default Scope in Rails - EN +- Beware of using default scope - EN +- default_scope - Ruby on Rails documentation - EN diff --git a/_includes/toc.html b/_includes/toc.html new file mode 100644 index 0000000..6b0552e --- /dev/null +++ b/_includes/toc.html @@ -0,0 +1,15 @@ +{% if site.lang == 'en' %} +{% assign title = 'Table of contents' %} +{% elsif site.lang == 'pl' %} +{% assign title = 'Spis treści' %} +{% endif %} + + diff --git a/images/default-scope/default-scope-header.png b/images/default-scope/default-scope-header.png new file mode 100644 index 0000000..57c48f8 Binary files /dev/null and b/images/default-scope/default-scope-header.png differ diff --git a/images/default-scope/default-scope-header.webp b/images/default-scope/default-scope-header.webp new file mode 100644 index 0000000..14a9a01 Binary files /dev/null and b/images/default-scope/default-scope-header.webp differ diff --git a/images/default-scope/og_image.png b/images/default-scope/og_image.png new file mode 100644 index 0000000..a715ec4 Binary files /dev/null and b/images/default-scope/og_image.png differ