lighthouse-graphql 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8545d3d90944dac8ea9e55b6fc19064b1895b160e2c2bd9459264456eed12dc8
4
- data.tar.gz: eb9631fdd402f4e699a0976ee6f8c5592b6a3231a7fc52d883c40ed7ea2b0a7a
3
+ metadata.gz: 16b866dbbae1192cb051baae53de5a7938f9bb16c1f176a5112d8b1893cef78c
4
+ data.tar.gz: b0a9ed4c1fdfe4e6d22b7ebb5d07ff192ea661dbaaf7751cc726854edbbdb4b7
5
5
  SHA512:
6
- metadata.gz: b443a6a974eaf9fad8ceab3eb3037a8fc03fc7de0d1d4944c5ffd26205fafed5137a12a680823dad5b74f5f2094d217ab2d9002abac44de4981352631e193c82
7
- data.tar.gz: 929747b79b50bdeca4da61a47eecbab7c09a41a95ec3eea3797fc7e8d2e93a5c509105f0edd35873a007988eb1971c79690c1a76a411bc1133d24c5620f0caee
6
+ metadata.gz: 485c2dcd6ade1ff9f1c62be69544114784c18ebedf2e25609791d9113eccdf97ef6b41824fb7b6bf6d0fffa86a95d14aac6f43eab91c21109bf3ec592ffb8192
7
+ data.tar.gz: 14a7e8b8718f655c53e9c7594099ba9060b9b34c60eb23a08a258c36856fa459c4da5aaa38ec99c74f32248cc365131a06176928ace9aa46cfbf65cc0e8458ec
data/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.1] - 2026-10-30
10
+
11
+ ### Added
12
+
13
+ - **`@search` over several models**: a field of a union or an interface (`everything(search: String!
14
+ @search): [SearchResult!]! @paginate`) searches the models of its types together, one ranked list with an
15
+ exact total, each record answered as its model's type. Searchkick searches their indices in one request,
16
+ each model's `search_where` and hidden records holding in its own index only (`_index`), each model's hits
17
+ loading through its own scopes; pg_search searches its multisearch documents, each one counted only when
18
+ its model's relation selects its record. The schema is refused when the field is not a root `@all` or
19
+ `@paginate`, has no non-null `@search` argument, has a type that is no model's or models no one engine
20
+ searches together, or asks for what one model's search applies (`scopes:`, `@builder`, `@whereAuth`,
21
+ `@orderBy(column:)`, `@trashed`). An adapter of its own opts in with `searchable_together?` and
22
+ `together_error`. See [filtering-and-ordering.md](docs/filtering-and-ordering.md#searching-several-models-together).
23
+
9
24
  ## [0.9.0] - 2026-10-30
10
25
 
11
26
  ### Added
@@ -1016,7 +1031,8 @@ had been running inside a private monorepo as a path gem before being extracted.
1016
1031
  - Config-driven extension seams: `Lighthouse.configure`, `resolver_namespaces`,
1017
1032
  `on_error`, and `DirectiveRegistry.register` for app-defined directives.
1018
1033
 
1019
- [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.9.0...HEAD
1034
+ [Unreleased]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.9.1...HEAD
1035
+ [0.9.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.9.0...v0.9.1
1020
1036
  [0.9.0]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.8.2...v0.9.0
1021
1037
  [0.8.2]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.8.1...v0.8.2
1022
1038
  [0.8.1]: https://github.com/Vaz-Innovation/lighthouse-graphql/compare/v0.8.0...v0.8.1
data/docs/directives.md CHANGED
@@ -694,6 +694,10 @@ each engine up.
694
694
  filters the database can apply too (`@trashed`'s, `@eq` on a column), so a stale index never
695
695
  answers one that no longer matches.
696
696
 
697
+ On a field of a union or an interface (`[SearchResult!]! @paginate`), the search runs over the models
698
+ of its types together, one ranked list: [Searching several models
699
+ together](filtering-and-ordering.md#searching-several-models-together).
700
+
697
701
  A model's `search_results` scope, when it has one, is what a search of it may return:
698
702
  Searchkick loads the records it finds through it, so a record the index still holds after it
699
703
  should have left is not returned. A search counts only what it can load: a Searchkick model
@@ -684,6 +684,85 @@ class Found
684
684
  end
685
685
  ```
686
686
 
687
+ ### Searching several models together
688
+
689
+ A field of a union or an interface searches the models of its types together, in one search: a
690
+ global search box that finds posts and users at once, the best matches of both first. PHP Lighthouse
691
+ has no such search (Scout searches one model).
692
+
693
+ ```graphql
694
+ union SearchResult = Post | User
695
+
696
+ type Query {
697
+ everything(search: String! @search): [SearchResult!]! @paginate
698
+ }
699
+ ```
700
+
701
+ ```graphql
702
+ {
703
+ everything(search: "rails", first: 10) {
704
+ data { __typename ... on Post { title } ... on User { name } }
705
+ paginatorInfo { total }
706
+ }
707
+ }
708
+ ```
709
+
710
+ The models are those of the union's types, or of the object types that implement the interface: each
711
+ type is a model's (`@model(class:)`, or its name). Each record answers as its model's type. One search
712
+ over all of them answers one ranked list, paged as any search (`@all`, `@paginate` of any type), with an
713
+ exact total.
714
+
715
+ - **Searchkick** searches the models' indices in one request (`Searchkick.search(models: [...])`). What
716
+ narrows a model holds in its own index only, through the engine's `_index` (which Elasticsearch and
717
+ OpenSearch match against the alias Searchkick names): its `search_where`, and the records its default
718
+ scope hides. Each model's hits load through its own default scope, `search_results` scope and the
719
+ conditions the database applies alike, as for a search of it alone, so a model with a default scope or
720
+ `search_results` still needs `search_where`.
721
+ - **pg_search** searches its [multisearch](https://github.com/Casecommons/pg_search#multi-search)
722
+ documents (`pg_search_documents`), which each `multisearchable` model keeps, ranked together by
723
+ `PgSearch.multisearch_options`. A document counts only when its record is one its model's relation
724
+ selects (its default scope, its `search_results` scope, the field's `@eq`), narrowed in the database, so
725
+ a page and its total hold only what loads, whatever documents are left behind (a record deleted without
726
+ its callbacks).
727
+
728
+ ```ruby
729
+ class Post < ApplicationRecord
730
+ include PgSearch::Model
731
+ multisearchable against: %i[title body], if: :published?
732
+ end
733
+
734
+ class User < ApplicationRecord
735
+ include PgSearch::Model
736
+ multisearchable against: :name
737
+ end
738
+
739
+ # config/initializers/pg_search.rb
740
+ PgSearch.multisearch_options = { using: { tsearch: { prefix: true } } }
741
+ ```
742
+
743
+ The field's other arguments narrow every model alike: `@eq` holds in each index (or each model's
744
+ relation), and matches nothing in one that does not have the column. `@builder` on an argument writes to
745
+ the search as for one model (with pg_search, it may not set Searchkick's options). `@canQuery` checks each
746
+ record the search finds by its own policy.
747
+
748
+ The schema is refused when it is built, as `nothing applies` would be, where such a field:
749
+
750
+ - is not a root field with `@all` or `@paginate` (a `model:` on the directive searches that one model,
751
+ as any field);
752
+ - has no search to run: there is no table to list without one, so it takes one `@search`, on a top-level,
753
+ non-null argument (`search: String!`), with no `within:`, which names one model's index or scope;
754
+ - has a type that is no model's, or models no one engine searches together (a Searchkick model and a
755
+ pg_search one; a pg_search model that is not `multisearchable`), or a model the engine cannot search as
756
+ it is (Searchkick's `search_where`, above);
757
+ - asks for what one model's search would apply, and a search of several cannot: `scopes:`, `@builder`,
758
+ `@whereAuth` or `@orderBy(column:)` on the field, `@trashed` (each model has its own soft deletes),
759
+ `scopes:` on `@canQuery`, or a default value for a directive the engine cannot apply.
760
+
761
+ An adapter of your own searches models together when it answers `searchable_together?(model)`,
762
+ `together_error(model)` (why the model cannot be searched with others as it is, or nil) and `page` for a
763
+ `Lighthouse::Search::Query` that has `models` (and no `model`); `query.for(model)` is the same search of
764
+ one of them.
765
+
687
766
  ### Choosing the engine
688
767
 
689
768
  By default the engine is detected per model: Searchkick if it calls `searchkick`, else pg_search if it
@@ -33,10 +33,10 @@ class Lighthouse::GraphQL::DirectiveResolvers::AllDirectiveResolver < Lighthouse
33
33
  private
34
34
 
35
35
  def resolve_all(model_class, arguments, ctx)
36
+ args = arguments.respond_to?(:to_h) ? arguments.to_h : {}
37
+ return search_records(search_together(args, ctx), args, ctx) if searched_together
36
38
  return [] unless model_class
37
39
 
38
- # apply simple pagination args if present
39
- args = arguments.respond_to?(:to_h) ? arguments.to_h : {}
40
40
  relation = apply_scopes(model_class.all)
41
41
 
42
42
  relation = apply_where_filters(relation, args, ctx, search: :model)
@@ -149,6 +149,23 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
149
149
  Lighthouse::Search::Enhancer.new(arguments, relation, search: search, context: ctx, field: field).enhance
150
150
  end
151
151
 
152
+ public
153
+
154
+ # The models the field searches together (a union's or an interface's types': Search.together),
155
+ # or nil for a field of one model.
156
+ def searched_together = Lighthouse::Search.together(@field_def)
157
+
158
+ # The search of the models the field searches together, narrowed by the field's argument
159
+ # filters: a Search::Result. Its errors fail the field, as the filters' (Support::Failures).
160
+ def search_together(args, ctx)
161
+ Lighthouse::Support::Failures.strictly(ctx) do
162
+ builders = @field_def.ast_node ? arg_builders(args_hash(args)) : []
163
+ unconstrained!(apply_search(nil, builders, ctx, searched_together))
164
+ end
165
+ end
166
+
167
+ private
168
+
152
169
  # Does the field have an argument or input field with @search? Known when the schema is built.
153
170
  def searching?
154
171
  @field_def.ast_node && Lighthouse::Search.searching?(@field_def)
@@ -67,7 +67,7 @@ module Lighthouse::GraphQL::DirectiveResolvers
67
67
  # A field whose model cannot be told fails the build, not a query. On an
68
68
  # argument, the model is looked for only when the client gives it a value:
69
69
  # an input type may be shared with fields that have no model.
70
- model_class if self.class.model? && !@opts[:argument]
70
+ model_class if self.class.model? && !@opts[:argument] && !searched_together
71
71
  me = self
72
72
  inner = capture_inner(target_type_class)
73
73
  install(target_type_class, lambda do |obj, args, ctx|
@@ -225,7 +225,7 @@ module Lighthouse::GraphQL::DirectiveResolvers
225
225
  # most, and more that a page could reach is an error (Search::Result#every_record).
226
226
  # Answers the Search::Result it checked, else nil.
227
227
  def authorize_before(_obj, args, ctx)
228
- found = apply_where_filters(scoped(model_class.all), args, ctx, search: :model)
228
+ found = searched_together ? search_together(args, ctx) : apply_where_filters(scoped(model_class.all), args, ctx, search: :model)
229
229
  searched = found.is_a?(Lighthouse::Search::Result)
230
230
  authorize_each!(ctx, searched ? found.every_record : found.to_a, args)
231
231
  found if searched
@@ -93,6 +93,8 @@ class Lighthouse::GraphQL::DirectiveResolvers::PaginateDirectiveResolver < Light
93
93
  # the other parents' when the Dataloader can; otherwise, or with `model:`,
94
94
  # the model's whole table.
95
95
  def page_list(obj, args, ctx)
96
+ return search_together(args, ctx) if searched_together
97
+
96
98
  name = Lighthouse::Support::Naming.attribute(@field_def.name)
97
99
  return decorate_relation(model_class.all, args, ctx, search: :model) unless nested?(obj, name)
98
100
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lighthouse
4
4
  module GraphQL
5
- VERSION = '0.9.0'
5
+ VERSION = '0.9.1'
6
6
  end
7
7
  end
@@ -116,6 +116,7 @@ require_relative 'namespaces'
116
116
  search/auto
117
117
  search/enhancer
118
118
  search/field_check
119
+ search/together_check
119
120
  relation_batch
120
121
  relation_counts
121
122
  nested_mutations
@@ -134,6 +134,8 @@ module Lighthouse::GraphQL::SchemaGenerator
134
134
  # `bases`: { root kind => the base class its fields' classes inherit from } (ResolverBases).
135
135
  def post_process(schema, _impl, bases: {}, **_opts)
136
136
  root_kinds = root_kinds(schema)
137
+ # A field of a union or an interface with @search searches its types' models together.
138
+ Lighthouse::Search::TogetherCheck.check_schema!(schema, root_kinds)
137
139
 
138
140
  # Inspect types and fields for directives and attach generated resolvers
139
141
  schema.types.each_value do |type|
@@ -18,4 +18,9 @@ class Lighthouse::Search::Auto
18
18
  end
19
19
 
20
20
  def searchable?(model, index = nil) = !adapter_for(model, index).nil?
21
+
22
+ # The first adapter that searches every one of `models` together, or nil.
23
+ def adapter_together(models)
24
+ @adapters.find { |adapter| adapter.respond_to?(:together_error) && models.all? { |model| adapter.searchable_together?(model) } }
25
+ end
21
26
  end
@@ -11,6 +11,8 @@
11
11
  # write to a Search::Query (Contracts::SearchBuilder), and the answer is a
12
12
  # Search::Result instead of a relation. Any other argument directive the client
13
13
  # sends is an error, as is a second @search.
14
+ # - For models searched together (`models`: a union's or an interface's), there is no
15
+ # relation: the search is one over all of them, a Search::Result, whatever the adapter.
14
16
  class Lighthouse::Search::Enhancer
15
17
  # An argument directive of the field: its AST nodes, the builder class (or :builder), the client's value.
16
18
  Argument = Struct.new(:node, :directive, :builder, :value, :present)
@@ -28,18 +30,21 @@ class Lighthouse::Search::Enhancer
28
30
  # nil, a relation only; :model, a Search::Result for the model's own relation it
29
31
  # built (with the `scopes:` Search::FieldCheck refuses for an engine), searched
30
32
  # whole without a check; :given, one for a relation the app's code gave, checked.
31
- # `field`: "Type.field", for the client's errors.
33
+ # `field`: "Type.field", for the client's errors. `search` is the models searched together, in
34
+ # place of a relation (nil), for a field of a union's or an interface's (Search.together).
32
35
  def initialize(arguments, relation, search:, context:, field: 'the field')
33
36
  @arguments = arguments
34
37
  @relation = relation
35
38
  @search = search
36
39
  @context = context
37
40
  @field = field
41
+ @models = search if search.is_a?(Array)
38
42
  end
39
43
 
40
44
  # The relation, searched; or a Search::Result. The relation itself if the client sent no search.
41
45
  def enhance
42
46
  search = the_search
47
+ return together(search) if @models
43
48
  return @relation unless search
44
49
 
45
50
  model = @relation.klass
@@ -73,6 +78,25 @@ class Lighthouse::Search::Enhancer
73
78
 
74
79
  searchable!(adapter, query.model)
75
80
  whole_index!(adapter) if @search == :given
81
+ narrowed(adapter, query)
82
+ end
83
+
84
+ # The search of the models, together: the field's argument is non-null (Search::FieldCheck), so
85
+ # the client always sends one.
86
+ def together(search)
87
+ raise ::GraphQL::ExecutionError, "#{@field} searches #{@models.map(&:name).join(', ')} together: it needs a search." unless search
88
+
89
+ adapter = Lighthouse::Search.adapter_together(@models) ||
90
+ raise(Lighthouse::DefinitionError, "@search: no engine searches #{@models.map(&:name).join(', ')} together.")
91
+ @models.each do |model|
92
+ error = adapter.together_error(model)
93
+ raise Lighthouse::DefinitionError, "@search: #{error}." if error
94
+ end
95
+ narrowed(adapter, Lighthouse::Search::Query.new(nil, search.value, models: @models))
96
+ end
97
+
98
+ # The search, narrowed by the other arguments the client sent, which the engine must take.
99
+ def narrowed(adapter, query)
76
100
  reject_incompatible
77
101
  query = @arguments.reject { |argument| search?(argument) }.reduce(query) { |narrowed, argument| narrow(narrowed, argument) }
78
102
  Lighthouse::Search::Result.checked(adapter, query, @field) || Lighthouse::Search::Result.new(adapter, query, context: @context, field: @field)
@@ -18,6 +18,8 @@
18
18
  # user must not see. A relational adapter (pg_search) applies them.
19
19
  # - An @canQuery, which runs the search to check the records it finds, searches
20
20
  # a searchable model, and without `scopes:` when the engine cannot apply them.
21
+ #
22
+ # A field of a union or an interface whose models are searched together is TogetherCheck's.
21
23
  class Lighthouse::Search::FieldCheck
22
24
  HINT = 'it needs searchkick or pg_search, or an adapter in Config.search'
23
25
  def self.check!(...) = new(...).check!
@@ -33,7 +35,7 @@ class Lighthouse::Search::FieldCheck
33
35
 
34
36
  def check!
35
37
  searches = Lighthouse::Search.directives(@field_def)
36
- return if searches.empty?
38
+ return if searches.empty? || Lighthouse::Search.together(@field_def)
37
39
 
38
40
  searches.each { |path, directive| within_string!(path, directive) }
39
41
  applied!(searches.first.first)
@@ -13,6 +13,14 @@
13
13
  # The model's `search_results` scope, if it has one, narrows the search too
14
14
  # (Lighthouse::Search).
15
15
  #
16
+ # Models searched together (Search::Together) are searched through pg_search's
17
+ # multisearch, the documents each `multisearchable` model keeps in one table
18
+ # (PgSearch::Document, `pg_search_documents`), ranked together. A document counts only
19
+ # when its record is one its model's relation selects (the model's default scope, its
20
+ # search_results scope, the field's @eq on a column of its): the documents are narrowed
21
+ # in the database, so pages and totals hold only what loads. Each page loads its records
22
+ # through the same relations, a query per model.
23
+ #
16
24
  # The gem is not a dependency of Lighthouse: the adapter is used when the
17
25
  # model includes PgSearch::Model.
18
26
  class Lighthouse::Search::PgSearch
@@ -42,8 +50,70 @@ class Lighthouse::Search::PgSearch
42
50
  orders.empty? ? searched : searched.reorder(*orders)
43
51
  end
44
52
 
53
+ # Models searched together: those that include PgSearch::Model, which must keep multisearch
54
+ # documents (`multisearchable`), whether or not they have a pg_search scope of their own.
55
+ def searchable_together?(model) = !!(defined?(::PgSearch::Model) && model.include?(::PgSearch::Model))
56
+
57
+ def together_error(model)
58
+ return nil if model.respond_to?(:pg_search_multisearchable_options)
59
+
60
+ "#{model.name} keeps no pg_search multisearch documents, which models searched together are searched through: " \
61
+ 'call `multisearchable against: [...]` in it'
62
+ end
63
+
64
+ # Models searched together: [one entry per document at `offset`, `limit` of them, in rank
65
+ # order: its record, or nil for one that did not load; how many documents match].
66
+ def page(query, offset:, limit:)
67
+ options!(query)
68
+ documents = documents(query)
69
+ found = limit.zero? ? [] : documents.offset(offset).limit(limit).to_a
70
+ [records(query, found), documents.unscope(:order, :select).count]
71
+ end
72
+
73
+ # pg_search has no result window.
74
+ def result_window(_query) = nil
75
+
45
76
  private
46
77
 
78
+ # The documents that match, in rank order, each one's record selected by its model's relation.
79
+ def documents(query)
80
+ kept = query.models.map do |model|
81
+ ::PgSearch::Document.where(searchable_type: model.polymorphic_name, searchable_id: relation(query.for(model)).select(model.primary_key))
82
+ end
83
+ ::PgSearch.multisearch(query.term).where(id: kept.reduce(:or).select(:id))
84
+ end
85
+
86
+ # The model's records a search of it may answer: its relation and search_results scope, with the
87
+ # field's @eq conditions; none where a condition names a column the model does not have.
88
+ def relation(query)
89
+ model = query.model
90
+ query.wheres.reduce(Lighthouse::Search.results(model.all)) do |relation, (column, value)|
91
+ model.column_names.include?(column) ? relation.where(column => value) : relation.none
92
+ end
93
+ end
94
+
95
+ # The records of `documents`, in their order, nil for one its model's relation no longer selects.
96
+ # The models that share a document type (an STI class and its subclasses) each load theirs.
97
+ def records(query, documents)
98
+ loaded = documents.group_by(&:searchable_type).to_h do |type, typed|
99
+ ids = typed.map(&:searchable_id)
100
+ found = query.models.select { |model| model.polymorphic_name == type }.flat_map do |model|
101
+ relation(query.for(model)).where(model.primary_key => ids).to_a
102
+ end
103
+ [type, found.index_by { |record| record.id.to_s }]
104
+ end
105
+ documents.map { |document| loaded.dig(document.searchable_type, document.searchable_id.to_s) }
106
+ end
107
+
108
+ # An @builder's Searchkick options, which multisearch cannot take: one that narrows what loads
109
+ # (`scope_results:`) would be dropped.
110
+ def options!(query)
111
+ return if query.options.empty?
112
+
113
+ raise Lighthouse::DefinitionError,
114
+ "@search: pg_search's multisearch takes no option of Searchkick's, which an @builder set: #{query.options.keys.join(', ')}"
115
+ end
116
+
47
117
  def scope_name(index) = (index || DEFAULT_SCOPE).to_sym
48
118
 
49
119
  # Is the model's class method `name` a pg_search scope? pg_search keeps no list
@@ -4,10 +4,16 @@
4
4
  # what narrows it. The directives of the other arguments write to it
5
5
  # (Contracts::SearchBuilder), and the adapter runs it (Search::Searchkick#page).
6
6
  # Directives never know which engine is behind it.
7
+ #
8
+ # A search of several models together (a union's or an interface's: Search::Together) has
9
+ # `models` and no `model`; `for(model)` is the same search of one of them.
7
10
  class Lighthouse::Search::Query
8
11
  # The model searched, the `@search` argument's value, the `within:` index (nil: the model's own).
9
12
  attr_reader :model, :term, :index
10
13
 
14
+ # The models searched together, or nil for a search of one.
15
+ attr_reader :models
16
+
11
17
  # [[column, value], ...], from @eq (and @builder), in order: all of them hold,
12
18
  # the same column twice too (a list of inputs), as the database applies them.
13
19
  attr_reader :wheres
@@ -18,15 +24,28 @@ class Lighthouse::Search::Query
18
24
  # :with, :only or :without, from @trashed; nil: the client did not say.
19
25
  attr_accessor :trashed
20
26
 
21
- def initialize(model, term, index = nil)
27
+ def initialize(model, term, index = nil, models: nil)
22
28
  @model = model
23
29
  @term = term
24
30
  @index = index
31
+ @models = models
25
32
  @wheres = []
26
33
  @options = {}
27
34
  @trashed = nil
28
35
  end
29
36
 
37
+ # The model's name, or the names of the models searched together, for errors.
38
+ def name = models ? models.map(&:name).join(', ') : model.name
39
+
40
+ # The same search of `model` alone: its term, conditions and options.
41
+ def for(model)
42
+ self.class.new(model, term, index).tap do |one|
43
+ wheres.each { |column, value| one.where(column, value) }
44
+ one.options.merge!(options)
45
+ one.trashed = trashed
46
+ end
47
+ end
48
+
30
49
  # The column equals the value, as Scout's `where`.
31
50
  def where(column, value)
32
51
  @wheres << [column.to_s, value]
@@ -142,7 +142,7 @@ class Lighthouse::Search::Result
142
142
  # Is it the search of `field` that `adapter` runs for `query`? Its options aside,
143
143
  # which may hold an @builder's procs, made again for each search.
144
144
  def same_search?(adapter, query, field)
145
- @adapter.equal?(adapter) && @field == field && query.model.equal?(@query.model) &&
145
+ @adapter.equal?(adapter) && @field == field && query.model.equal?(@query.model) && query.models == @query.models &&
146
146
  [query.term, query.index, query.wheres, query.trashed] == [@query.term, @query.index, @query.wheres, @query.trashed]
147
147
  end
148
148
 
@@ -176,6 +176,6 @@ class Lighthouse::Search::Result
176
176
 
177
177
  raise ::GraphQL::ExecutionError,
178
178
  "The search reads no further than its first #{result_window} matches (the index.max_result_window of " \
179
- "#{@query.model.name}), so offset #{offset} is past them. Narrow the search instead."
179
+ "#{@query.name}), so offset #{offset} is past them. Narrow the search instead."
180
180
  end
181
181
  end
@@ -53,16 +53,20 @@ class Lighthouse::Search::Searchkick
53
53
  # [one entry per hit at `offset`, `limit` of them, in relevance order: the
54
54
  # record, or nil for a hit that did not load; the total that match].
55
55
  def page(query, offset:, limit:)
56
- response = query.model.searchkick_search(query.term, **search_options(query), offset: offset, limit: limit)
56
+ response = search(query, offset, limit)
57
57
  exact_total!(query, response)
58
58
  [entries(response), response.total_count]
59
59
  rescue StandardError => e
60
60
  raise unless past_window?(e)
61
61
 
62
62
  # The index's window is smaller than the model's options say: set on the server.
63
- raise ::GraphQL::ExecutionError, "The search reads no further than the index.max_result_window of #{query.model.name}. Narrow the search instead."
63
+ raise ::GraphQL::ExecutionError, "The search reads no further than the index.max_result_window of #{query.name}. Narrow the search instead."
64
64
  end
65
65
 
66
+ # Models searched together: those it searches alone, and why one cannot be as it is (model_error).
67
+ def searchable_together?(model) = searchable?(model)
68
+ def together_error(model) = model_error(model)
69
+
66
70
  # How many of the matches the index the search reads pages through
67
71
  # (Search::Result): its index.max_result_window, read from the engine
68
72
  # (Searchkick::Index). When the settings cannot be read, the model's own index
@@ -70,6 +74,8 @@ class Lighthouse::Search::Searchkick
70
74
  # its index `settings:`, whatever their keys; a billion with `deep_paging:
71
75
  # true`), and any index the engines' default, 10,000.
72
76
  def result_window(query)
77
+ return query.models.map { |model| result_window(query.for(model)) }.min if query.models
78
+
73
79
  @index.result_window(query.model, index_name(query)) ||
74
80
  (own_index?(query) && options_window(query.model)) || Lighthouse::Search::Result::MAX_WINDOW
75
81
  end
@@ -133,7 +139,7 @@ class Lighthouse::Search::Searchkick
133
139
  return unless total.is_a?(Hash) && total['relation'] && total['relation'] != 'eq'
134
140
 
135
141
  raise Lighthouse::DefinitionError,
136
- "@search on #{query.model.name}: the engine counted #{total['relation']} #{total['value']} matches, not every one, " \
142
+ "@search on #{query.name}: the engine counted #{total['relation']} #{total['value']} matches, not every one, " \
137
143
  'which totals, pages and @canQuery need (track_total_hits)'
138
144
  end
139
145
 
@@ -154,14 +160,51 @@ class Lighthouse::Search::Searchkick
154
160
  defined?(::Searchkick::InvalidQueryError) && error.is_a?(::Searchkick::InvalidQueryError) && error.message.include?('max_result_window')
155
161
  end
156
162
 
163
+ # The engine's response to the search, `limit` hits from `offset`: of the model, or of the
164
+ # models searched together, in one request over their indices.
165
+ def search(query, offset, limit)
166
+ return query.model.searchkick_search(query.term, **search_options(query), offset: offset, limit: limit) unless query.models
167
+
168
+ ::Searchkick.search(query.term, models: query.models, **together_options(query), offset: offset, limit: limit)
169
+ end
170
+
157
171
  def search_options(query)
158
172
  options = Lighthouse::Search::Searchkick::Options.builder!(query)
159
173
  wheres = query.wheres.map { |column, value| { column => value } }
160
174
  options[:where] = all_of(options[:where], engine_where(query), *wheres, trashed_filter(query))
161
175
  options[:index_name] = query.index if query.index
176
+ loaded(options, results_scope(query, options[:scope_results]))
177
+ end
178
+
179
+ # The options of a search of several models, in one request over their indices. What narrows
180
+ # each model holds in its own index only (`_index`, which the engines match against the
181
+ # alias Searchkick names): its search_where, and the records its default scope hides; what the
182
+ # client sends (@eq) holds in every index, matching nothing in one that does not hold the
183
+ # column. Each model's records load through its own narrowing (results_scope), which Searchkick
184
+ # calls with each model's relation.
185
+ def together_options(query)
186
+ options = Lighthouse::Search::Searchkick::Options.builder!(query)
187
+ wheres = query.wheres.map { |column, value| { column => value } }
188
+ each_own = query.models.map { |model| own_where(query.for(model)) }
189
+ options[:where] = all_of(options[:where], *wheres, { _or: each_own })
190
+ loads = query.models.index_with { |model| results_scope(query.for(model), options[:scope_results]) }.compact
191
+ loaded(options, loads.empty? ? nil : ->(records) { (loads[model_of(records)] || :itself.to_proc).call(records) })
192
+ end
193
+
194
+ # What Searchkick loads a model's hits through: the model itself, or a relation of it.
195
+ def model_of(records) = records.respond_to?(:klass) ? records.klass : records
196
+
197
+ # What one model's hits must hold in a search of several: being in its index, and what its own
198
+ # search would have the engine apply.
199
+ def own_where(query)
200
+ all_of({ _index: query.model.searchkick_index.name }, engine_where(query), trashed_filter(query))
201
+ end
202
+
203
+ # The options Lighthouse sets whatever the search: the records load, through `loading` (the
204
+ # `scope_results:` that narrows them) if any, and every match is counted.
205
+ def loaded(options, loading)
162
206
  options[:load] = true
163
207
  options[:body_options] = Lighthouse::Search::Searchkick::Options.counting(options)
164
- loading = results_scope(query, options[:scope_results])
165
208
  options[:scope_results] = loading if loading
166
209
  options
167
210
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A field of a union or an interface (with @all or @paginate, and no `model:`) whose @search
4
+ # searches the models of its types together, in one search, checked when the schema is built
5
+ # (SchemaGenerator, before FieldCheck, which leaves the field to it), as FieldCheck checks a
6
+ # field of one model. It keeps the models for the field's resolvers (Search.together!).
7
+ #
8
+ # Each type is a model's (@model, or its name), one engine searches them all
9
+ # (`adapter_together`, `together_error`), the @search argument is a top-level, non-null one,
10
+ # with no `within:`, on a root field; and nothing a model would apply alone, but the engine
11
+ # across them cannot, is asked for: `scopes:`, @builder, @whereAuth, @orderBy(column:),
12
+ # @trashed, a default value the engine cannot apply, an @canQuery's `scopes:`.
13
+ class Lighthouse::Search::TogetherCheck < Lighthouse::Search::FieldCheck
14
+ # Every field of the schema's object types (`root_kinds`: SchemaGenerator#root_kinds).
15
+ def self.check_schema!(schema, root_kinds)
16
+ schema.types.each_value do |type|
17
+ next unless type.kind.object?
18
+
19
+ type.fields.each_value { |field_def| check!(schema, type, field_def, root_kinds[type.graphql_name]) if field_def.owner == type }
20
+ end
21
+ end
22
+
23
+ def self.check!(schema, type, field_def, root_kind)
24
+ return unless field_def.ast_node
25
+
26
+ new(schema, type, field_def, root_kind).check!
27
+ end
28
+
29
+ def initialize(schema, type, field_def, root_kind)
30
+ super(type, field_def, field_def.ast_node.directives || [], root_kind)
31
+ @schema = schema
32
+ end
33
+
34
+ def check!
35
+ searches = Lighthouse::Search.directives(@field_def)
36
+ together!(searches) if searches.any? && together?
37
+ end
38
+
39
+ private
40
+
41
+ # Does the field search the models of a union's or an interface's types together? One that a
42
+ # data directive resolves without a `model:` of its own.
43
+ def together?
44
+ !resolving_directive.nil? && returned&.kind&.abstract? &&
45
+ Lighthouse::Support::DirectiveArgs.literal(resolving_directive, 'model').nil?
46
+ end
47
+
48
+ # The type of what the field returns: of its items, for a paginated field (`PostPaginator`'s Post).
49
+ def returned
50
+ type = @field_def.type.unwrap
51
+ return type unless resolving_directive.name == 'paginate'
52
+
53
+ @schema.get_type(type.graphql_name.sub(Lighthouse::Support::ModelResolver::PAGINATION_SUFFIX, ''))
54
+ end
55
+
56
+ def together!(searches)
57
+ path = searches.first.first
58
+ searches.each { |each_path, directive| within_string!(each_path, directive) }
59
+ applied!(path)
60
+ together_field!(path, searches)
61
+ models = together_models(path)
62
+ adapter = together_adapter!(path, models)
63
+ together_constraints!(path, adapter)
64
+ can_queries.each { |can_query| together_can_query!(path, can_query) }
65
+ Lighthouse::Search.together!(@field_def, models)
66
+ end
67
+
68
+ # On a root field, with @all or @paginate, and one @search: a top-level, non-null argument
69
+ # (with no models to list, a field of several models answers a search, always), with no `within:`.
70
+ def together_field!(path, searches)
71
+ unless @root_kind && %w[all paginate].include?(resolving_directive.name)
72
+ fail!(path, "#{abstract_name} has several models, which only @all or @paginate on a root field search together " \
73
+ '(or give the directive the `model:` it searches)')
74
+ end
75
+ one_search!(path, searches)
76
+ return if Lighthouse::Support::DirectiveArgs.literal(searches.first.last, 'within').nil?
77
+
78
+ fail!(path, "`within:` names one model's index or scope, and #{abstract_name}'s models are searched together")
79
+ end
80
+
81
+ def one_search!(path, searches)
82
+ return if searches.size == 1 && @field_def.arguments[path]&.type&.non_null?
83
+
84
+ fail!(path, "a field that searches #{abstract_name}'s models together needs its search: one @search, on a non-null argument " \
85
+ '(`search: String!`), which there is no table to list without')
86
+ end
87
+
88
+ # The models of the union's or the interface's object types: each must be a model's.
89
+ def together_models(path)
90
+ types = @schema.possible_types(returned).sort_by(&:graphql_name)
91
+ types.map do |type|
92
+ Lighthouse::GraphQL::Directives::ModelDirective.model_for(type) ||
93
+ fail!(path, "#{abstract_name}'s type #{type.graphql_name} is no model's: give it @model(class:), or remove it")
94
+ end
95
+ end
96
+
97
+ def together_adapter!(path, models)
98
+ adapter = Lighthouse::Search.adapter_together(models) || no_engine!(path, models)
99
+ models.each do |model|
100
+ error = adapter.together_error(model)
101
+ fail!(path, error) if error
102
+ end
103
+ adapter
104
+ end
105
+
106
+ # What one model's search would apply, and a search of several cannot: the field's own query
107
+ # directives and `scopes:`, @trashed (each model has its own soft-delete column) and default
108
+ # values the engine cannot apply.
109
+ def together_constraints!(path, adapter)
110
+ name = adapter.class.name.demodulize
111
+ dropped = dropped_constraints
112
+ fail!(path, "#{name} cannot apply #{dropped.join(', ')} to models searched together; remove them") unless dropped.empty?
113
+ trashed = Lighthouse::Support::ArgumentWalk.field_definitions(@field_def).find do |_, argument|
114
+ Lighthouse::Support::ArgumentWalk.directives(argument).any? { |directive| directive.name == 'trashed' }
115
+ end
116
+ fail!(path, "@trashed on #{trashed.first} cannot filter models searched together, each with its own soft deletes") if trashed
117
+ defaulted_constraints.each do |constraint|
118
+ fail!(path, "#{name} cannot apply #{constraint}, whose default value would apply to every search; remove the default")
119
+ end
120
+ end
121
+
122
+ def no_engine!(path, models)
123
+ engines = models.map { |model| "#{model.name} (#{engine_name(model)})" }
124
+ fail!(path, "no engine searches #{abstract_name}'s models together: #{engines.join(', ')}. One engine searches them " \
125
+ 'all: Searchkick, or pg_search through its multisearch documents')
126
+ end
127
+
128
+ def engine_name(model) = Lighthouse::Search.adapter_for(model)&.class&.name&.demodulize || 'not searchable'
129
+
130
+ def together_can_query!(path, can_query)
131
+ return if Lighthouse::Support::DirectiveArgs.literal(can_query, 'scopes').blank?
132
+
133
+ fail!(path, 'the `scopes:` of @canQuery cannot narrow models searched together; remove them')
134
+ end
135
+
136
+ def abstract_name = returned.graphql_name
137
+ end
@@ -8,6 +8,12 @@
8
8
  # relational? is the search a scope on the model's relation (pg_search)?
9
9
  # scope(relation, query) relational: the relation narrowed to the matches, ranked
10
10
  # page(query, offset:, limit:) otherwise: [records, total] of the engine's response
11
+ # searchable_together?(model) can it search the model with others (optional, with together_error)?
12
+ # together_error(model) why it cannot search the model with others as it is, or nil
13
+ #
14
+ # Models searched together (a field of a union or an interface: FieldCheck) are one search
15
+ # over all of them, a Query with `models`, which the adapter answers with `page`, whatever it
16
+ # is: Searchkick over their indices, pg_search through its multisearch documents.
11
17
  #
12
18
  # `query` is a Search::Query. A relational adapter lets every other directive of
13
19
  # the field apply on top of the scope; the others (Searchkick) answer on their own
@@ -75,4 +81,21 @@ module Lighthouse::Search
75
81
 
76
82
  # Does the field have an argument or input field with @search?
77
83
  def searching?(field_def) = directives(field_def).any?
84
+
85
+ # The models the field searches together (Search::FieldCheck keeps them when the schema is
86
+ # built: the models of its union's or interface's types), or nil for a field of one model.
87
+ def together(field_def) = Lighthouse::Support::Memo.on(field_def, :searched_together) { nil }
88
+
89
+ def together!(field_def, models)
90
+ field_def.instance_variable_set(:@lighthouse_searched_together, models.freeze)
91
+ end
92
+
93
+ # The adapter that searches `models` together (`searchable_together?` each of them, and
94
+ # `together_error`), or nil when none does.
95
+ def adapter_together(models)
96
+ adapter = self.adapter
97
+ return adapter.adapter_together(models) if adapter.respond_to?(:adapter_together)
98
+
99
+ adapter if adapter.respond_to?(:together_error) && models.all? { |model| adapter.searchable_together?(model) }
100
+ end
78
101
  end
@@ -21,9 +21,18 @@ module Lighthouse
21
21
  # Does the field have an argument or input field with @search? Walked once per field.
22
22
  def self.searching?: (_FieldDefinition field_def) -> bool
23
23
 
24
+ # The models the field searches together (a union's or an interface's), kept when the schema is built; nil for one model.
25
+ def self.together: (_FieldDefinition field_def) -> Array[Class]?
26
+ def self.together!: (_FieldDefinition field_def, Array[Class] models) -> Array[Class]
27
+ # The adapter that searches the models together, or nil.
28
+ def self.adapter_together: (Array[Class] models) -> _TogetherSearchAdapter?
29
+
24
30
  # What a search holds: the model, the term and what narrows it.
25
31
  class Query
26
- attr_reader model: Class
32
+ # nil for models searched together.
33
+ attr_reader model: Class?
34
+ # The models searched together, or nil for a search of one.
35
+ attr_reader models: Array[Class]?
27
36
  attr_reader term: String
28
37
  attr_reader index: String?
29
38
  # [column, value] pairs, from @eq, all of which hold.
@@ -33,8 +42,12 @@ module Lighthouse
33
42
  # :with, :only or :without, from @trashed; nil: not given.
34
43
  attr_accessor trashed: (:with | :only | :without)?
35
44
 
36
- def initialize: (Class model, String term, ?String? index) -> void
45
+ def initialize: (Class? model, String term, ?String? index, ?models: Array[Class]?) -> void
37
46
  def where: (String | Symbol column, untyped value) -> self
47
+ # The model's name, or the names of the models searched together.
48
+ def name: () -> String
49
+ # The same search of one model.
50
+ def for: (Class model) -> Query
38
51
  end
39
52
 
40
53
  # A search an engine answers: the page it is asked for and its total.
@@ -74,6 +87,7 @@ module Lighthouse
74
87
  # Searchkick (Elasticsearch / OpenSearch), outside the database.
75
88
  class Searchkick
76
89
  include _EngineSearchAdapter
90
+ include _TogetherSearchAdapter
77
91
 
78
92
  DEFAULT_TRASHED_COLUMN: String
79
93
  DEEP_PAGING_WINDOW: Integer
@@ -114,9 +128,14 @@ module Lighthouse
114
128
  # pg_search (PostgreSQL full-text search), in the database.
115
129
  class PgSearch
116
130
  include _RelationalSearchAdapter
131
+ include _EngineSearchAdapter
132
+ include _TogetherSearchAdapter
117
133
 
118
134
  DEFAULT_SCOPE: Symbol
119
135
 
136
+ # Models searched together, through multisearch's documents (page), which have no result window.
137
+ def result_window: (Query query) -> nil
138
+
120
139
  # Does `index` name one of the model's pg_search scopes?
121
140
  def named?: (Class model, String? index) -> bool
122
141
  end
@@ -127,6 +146,8 @@ module Lighthouse
127
146
 
128
147
  def initialize: (?Array[_SearchAdapter]? adapters) -> void
129
148
  def searchable?: (Class model, ?String? index) -> bool
149
+ # The first adapter that searches every one of the models together, or nil.
150
+ def adapter_together: (Array[Class] models) -> _TogetherSearchAdapter?
130
151
  end
131
152
  end
132
153
  end
data/sig/lighthouse.rbs CHANGED
@@ -280,6 +280,14 @@ module Lighthouse
280
280
  def adapter_for: (Class model, ?String? index) -> _SearchAdapter?
281
281
  end
282
282
 
283
+ # An adapter that searches models together (a union's or an interface's): its `page` (as
284
+ # _EngineSearchAdapter's) answers a Search::Query with `models`, and `together_error(model)`
285
+ # why one cannot be searched with others as it is (a schema error).
286
+ interface _TogetherSearchAdapter
287
+ def searchable_together?: (Class model) -> boolish
288
+ def together_error: (Class model) -> String?
289
+ end
290
+
283
291
  # What DirectiveRegistry#register_manipulator takes: it transforms the SDL
284
292
  # before the schema is built.
285
293
  interface _SdlManipulator
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lighthouse-graphql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vaz Innovation
@@ -245,6 +245,7 @@ files:
245
245
  - lib/lighthouse/search/searchkick.rb
246
246
  - lib/lighthouse/search/searchkick_index.rb
247
247
  - lib/lighthouse/search/searchkick_options.rb
248
+ - lib/lighthouse/search/together_check.rb
248
249
  - lib/lighthouse/server_directives.rb
249
250
  - lib/lighthouse/subscription.rb
250
251
  - lib/lighthouse/support/argument_walk.rb