graphiti 2.0.2 → 2.1.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +5 -0
  3. data/.github/workflows/release.yml +5 -0
  4. data/CHANGELOG.md +20 -0
  5. data/bin/cut-docs-version +24 -0
  6. data/lib/generators/graphiti/generator_mixin.rb +1 -1
  7. data/lib/generators/graphiti/templates/application_resource.rb.erb +5 -5
  8. data/lib/graphiti/adapters/abstract.rb +8 -0
  9. data/lib/graphiti/adapters/active_record.rb +8 -0
  10. data/lib/graphiti/adapters/persistence/associations.rb +6 -1
  11. data/lib/graphiti/audit/report.rb +3 -1
  12. data/lib/graphiti/audit.rb +14 -1
  13. data/lib/graphiti/errors.rb +36 -0
  14. data/lib/graphiti/query.rb +7 -0
  15. data/lib/graphiti/request_validators/validator.rb +10 -1
  16. data/lib/graphiti/resource/configuration.rb +124 -4
  17. data/lib/graphiti/resource/dsl.rb +54 -0
  18. data/lib/graphiti/resource/interface.rb +39 -0
  19. data/lib/graphiti/resource/persistence.rb +29 -0
  20. data/lib/graphiti/resource/sideloading.rb +2 -0
  21. data/lib/graphiti/resource.rb +4 -0
  22. data/lib/graphiti/resource_proxy.rb +6 -3
  23. data/lib/graphiti/runner.rb +3 -1
  24. data/lib/graphiti/schema.rb +4 -0
  25. data/lib/graphiti/schema_diff.rb +5 -0
  26. data/lib/graphiti/scope.rb +8 -0
  27. data/lib/graphiti/scoping/default_filter.rb +2 -0
  28. data/lib/graphiti/scoping/filter.rb +79 -20
  29. data/lib/graphiti/scoping/sort.rb +1 -1
  30. data/lib/graphiti/sideload/belongs_to.rb +52 -8
  31. data/lib/graphiti/sideload/has_many.rb +33 -2
  32. data/lib/graphiti/sideload/many_to_many.rb +3 -3
  33. data/lib/graphiti/sideload.rb +25 -1
  34. data/lib/graphiti/stats/payload.rb +20 -3
  35. data/lib/graphiti/util/internal_param.rb +16 -0
  36. data/lib/graphiti/util/link.rb +10 -3
  37. data/lib/graphiti/util/persistence.rb +1 -1
  38. data/lib/graphiti/util/public_id_block.rb +15 -0
  39. data/lib/graphiti/util/public_id_map.rb +34 -0
  40. data/lib/graphiti/util/public_id_sources.rb +27 -0
  41. data/lib/graphiti/util/serializer_relationships.rb +5 -3
  42. data/lib/graphiti/version.rb +1 -1
  43. data/lib/graphiti.rb +16 -0
  44. metadata +6 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d65d4265eb6a753309e1348e135296d3c114b0497ceadd09ccc0f626d0fcaad
4
- data.tar.gz: c8e52211ff147a9607360ce19e2351fe10e5a670481e1d20cf2625034f761ed8
3
+ metadata.gz: 873ef782c20fd4c861ccc48b7d1be6ff79943a22a89bc823d1c929cd64a05e13
4
+ data.tar.gz: 32aab62c054dfc0cf73647fb018adf40efec5862bebddc727e16797857b2987b
5
5
  SHA512:
6
- metadata.gz: b8c3fcadbaba93c23bdf505937443432abc2e0b120a2baadd76279c20f7ba0382798a416dd83832e4ad0e9697e5cb20570df93c0747eea5c832782a1cf442865
7
- data.tar.gz: c3f790bed7b1cba2802e5a10b9c9f51c53ccc44e9da9823956266361f80a4af0df790d59ebe951f97768b4ba80da7a002ae919cc739225ed22cec8084e4292ce
6
+ metadata.gz: fac4855f042d4214bdc196609776efb739de5676d8c464dcffaeeda6f233639609bfb3c450608b394c81aee6470d04013a4989cead074b61a71cc645f3a9eb3e
7
+ data.tar.gz: 8119467fe8a1da3a711f7b597a19f2540f7170d2fa2fe6116d19086d029f8713fdeb976e090aea8a5662d54e7af1df576dc9f29b8c138c3367ba337fc2a7e02c
@@ -39,6 +39,11 @@ jobs:
39
39
  working-directory: website
40
40
  run: npm ci
41
41
 
42
+ - name: Find the latest release
43
+ run: |
44
+ git fetch --tags --quiet
45
+ echo "GRAPHITI_RELEASE=$(git tag --list 'v2*' --sort=-v:refname | head -1 | sed 's/^v//')" >> "$GITHUB_ENV"
46
+
42
47
  - name: Build
43
48
  working-directory: website
44
49
  run: npm run build
@@ -36,3 +36,8 @@ jobs:
36
36
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
37
37
  GEM_HOST_API_KEY: ${{ secrets.GEM_HOST_API_KEY }}
38
38
  run: npx semantic-release
39
+ # A release published with GITHUB_TOKEN never fires other workflows' release triggers, so the docs are rebuilt by hand.
40
+ - name: Rebuild docs with the new version label
41
+ env:
42
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43
+ run: gh workflow run docs.yml --ref "$GITHUB_REF_NAME"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  graphiti changelog
2
2
 
3
+ ## [2.1.1](https://github.com/graphiti-api/graphiti/compare/v2.1.0...v2.1.1) (2026-09-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * a filter you declare by hand always beats the one an attribute generates ([955875b](https://github.com/graphiti-api/graphiti/commit/955875b2a28ef67980e240399deb70620f44b935))
9
+
10
+ # [2.1.0](https://github.com/graphiti-api/graphiti/compare/v2.0.2...v2.1.0) (2026-09-04)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * attribute :id declared on an abstract resource is inherited instead of failing on the missing serializer ([8faf217](https://github.com/graphiti-api/graphiti/commit/8faf217bcf0d560ff81a067a8f3ef1473724b933)), closes [#151](https://github.com/graphiti-api/graphiti/issues/151)
16
+
17
+
18
+ ### Features
19
+
20
+ * declare resource settings in the DSL form (adapter :active_record, model Employee), matching `attribute :name` ([14f1722](https://github.com/graphiti-api/graphiti/commit/14f17222770ba8577048775a45c893e85aef7e94))
21
+ * hide your database ids from clients by specifying an alternate public_id, or an obfuscation transform ([81b97f9](https://github.com/graphiti-api/graphiti/commit/81b97f979bf35c3fa8c2eda8651a40a3f989e0ea)), closes [#409](https://github.com/graphiti-api/graphiti/issues/409)
22
+
3
23
  ## [2.0.2](https://github.com/graphiti-api/graphiti/compare/v2.0.1...v2.0.2) (2026-09-04)
4
24
 
5
25
 
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ cat <<'TEXT'
6
+ Usage: bin/cut-docs-version <version> e.g. bin/cut-docs-version 2.1
7
+
8
+ Freezes docs/ as website/versioned_docs/version-<version>, served at /<version>/.
9
+ docs/ itself keeps serving at the root as "Latest". Run it before merging docs
10
+ whose syntax the current release lacks, naming the release being left behind.
11
+ Review the new files and commit them.
12
+ TEXT
13
+ }
14
+
15
+ case "${1:-}" in
16
+ ""|-h|--help) usage; exit 0 ;;
17
+ esac
18
+
19
+ cd "$(dirname "$0")/../website"
20
+ [ -d node_modules ] || npm ci
21
+ npm run docusaurus docs:version "$1"
22
+
23
+ echo
24
+ echo "Cut $1. Review website/versioned_docs/version-$1 and commit it."
@@ -37,7 +37,7 @@ module Graphiti
37
37
  name_width = Graphiti::Resource::SETTINGS.keys.map(&:length).max
38
38
  assignments = Graphiti::Resource::SETTINGS.to_h do |name, setting|
39
39
  value = setting[:format] || setting[:default].inspect
40
- [name, "self.#{name.to_s.ljust(name_width)} = #{value}"]
40
+ [name, "#{name.to_s.ljust(name_width)} #{value}"]
41
41
  end
42
42
  hint_column = assignments.values.map(&:length).max + 2
43
43
 
@@ -2,14 +2,14 @@
2
2
  # All Resources should inherit from ApplicationResource.
3
3
  <%- end -%>
4
4
  class ApplicationResource < Graphiti::Resource
5
- self.abstract_class = true
6
- self.adapter = Graphiti::Adapters::ActiveRecord
5
+ abstract_class
6
+ adapter :active_record
7
7
  <%- unless omit_comments? -%>
8
8
  # Or follow config/environments:
9
- # self.base_url = ActionDispatch::Http::URL.url_for(Rails.application.routes.default_url_options)
9
+ # base_url ActionDispatch::Http::URL.url_for(Rails.application.routes.default_url_options)
10
10
  <%- end -%>
11
- self.base_url = ENV.fetch('BASE_URL', 'http://localhost:3000')
12
- self.endpoint_namespace = '<%= api_namespace %>'
11
+ base_url ENV.fetch('BASE_URL', 'http://localhost:3000')
12
+ endpoint_namespace '<%= api_namespace %>'
13
13
  <%- unless omit_comments? -%>
14
14
 
15
15
  # Defaults every Resource inherits. See https://graphiti.dev/concepts/resources
@@ -68,6 +68,14 @@ module Graphiti
68
68
  raise Errors::AdapterNotImplemented.new(self, attribute, :filter_string_not_eql)
69
69
  end
70
70
 
71
+ def filter_public_id_eq(scope, attribute, value)
72
+ filter_string_eq(scope, attribute, value)
73
+ end
74
+
75
+ def filter_public_id_not_eq(scope, attribute, value)
76
+ filter_string_not_eq(scope, attribute, value)
77
+ end
78
+
71
79
  def filter_string_prefix(scope, attribute, value)
72
80
  raise Errors::AdapterNotImplemented.new(self, attribute, :filter_string_prefix)
73
81
  end
@@ -61,6 +61,14 @@ module Graphiti
61
61
  filter_string_eql(scope, attribute, value.presence, is_not: true)
62
62
  end
63
63
 
64
+ def filter_public_id_eq(scope, attribute, value)
65
+ filter_string_eql(scope, attribute, value)
66
+ end
67
+
68
+ def filter_public_id_not_eq(scope, attribute, value)
69
+ filter_string_not_eql(scope, attribute, value)
70
+ end
71
+
64
72
  # Arel has different match escaping behavior before rails 5.
65
73
  # Since rails 4.x does not expose methods to escape LIKE statements
66
74
  # anyway, we just don't support proper LIKE escaping in those versions.
@@ -63,11 +63,16 @@ module Graphiti
63
63
  if x[:sideload].polymorphic_has_one? || x[:sideload].polymorphic_has_many?
64
64
  attrs[:"#{x[:sideload].polymorphic_as}_type"] = polymorphic_type_value(parent_object)
65
65
  end
66
- attrs[x[:foreign_key]] = parent_object.send(x[:primary_key])
66
+ attrs[x[:foreign_key]] = internal_foreign_key(parent_object.send(x[:primary_key]))
67
67
  update_foreign_type(attrs, x) if x[:is_polymorphic]
68
68
  end
69
69
  end
70
70
 
71
+ # Resource#assign decodes public ids out of foreign keys, and must leave a key Graphiti resolved itself alone.
72
+ def internal_foreign_key(value)
73
+ Graphiti.public_ids_declared? ? Graphiti::Util::InternalParam.new(value) : value
74
+ end
75
+
71
76
  def polymorphic_type_value(parent_object)
72
77
  parent_object.class.name
73
78
  end
@@ -7,7 +7,8 @@ module Graphiti
7
7
  broken_relationship: "raised while being inspected",
8
8
  missing_association_method: "will raise when the relationship is included: the model has no association method",
9
9
  missing_guard_method: "will raise whenever the resource renders: the readable guard is not defined",
10
- missing_sideload_filter: "will raise when the relationship is included: the related resource is missing the filter"
10
+ missing_sideload_filter: "will raise when the relationship is included: the related resource is missing the filter",
11
+ link_hidden: "renders no related link: it would expose a primary key the related resource hides"
11
12
  }.freeze
12
13
 
13
14
  CHECKLIST = {
@@ -15,6 +16,7 @@ module Graphiti
15
16
  missing_association_method: ["all association methods defined", "association method", "missing"],
16
17
  missing_guard_method: ["all readable guards defined", "readable guard", "missing"],
17
18
  missing_sideload_filter: ["all sideload filters declared", "sideload filter", "missing"],
19
+ link_hidden: ["all relationship links renderable", "relationship link", "hidden"],
18
20
  loads_on_every_render: ["all id-rendering loads preloaded", "relationship", "loading ids without preloading"]
19
21
  }.freeze
20
22
 
@@ -79,7 +79,8 @@ module Graphiti
79
79
  findings: [
80
80
  missing_association_method(sideload, model),
81
81
  missing_guard_method(resource_class, sideload),
82
- missing_sideload_filter(sideload)
82
+ missing_sideload_filter(sideload),
83
+ link_hidden(sideload)
83
84
  ].compact
84
85
  )
85
86
  rescue => error
@@ -195,6 +196,18 @@ module Graphiti
195
196
  )
196
197
  end
197
198
 
199
+ def link_hidden(sideload)
200
+ return if sideload.link_proc || sideload.link_hides_primary_key?
201
+ return unless sideload.requested_link_mode
202
+
203
+ Finding.new(
204
+ severity: :warning,
205
+ check: :link_hidden,
206
+ message: "#{target_name(sideload)} publishes a public id the relationship cannot translate to",
207
+ remedy: "declare the filter the relationship links through on the related resource, have its block take `primary_keys:`, or give the relationship a `link` block"
208
+ )
209
+ end
210
+
198
211
  def builds_its_own_query?(sideload)
199
212
  sideload.class.params_proc || sideload.class.scope_proc
200
213
  end
@@ -740,6 +740,42 @@ module Graphiti
740
740
  end
741
741
  end
742
742
 
743
+ class ConflictingPublicIdSource < Base
744
+ def initialize(resource_class, filter_name, registered, claimant)
745
+ @resource_class = resource_class
746
+ @filter_name = filter_name
747
+ @registered = registered
748
+ @claimant = claimant
749
+ end
750
+
751
+ def message
752
+ "#{@resource_class.name}: filter #{@filter_name.inspect} already resolves public ids through #{@registered.name}, so #{@claimant.name} cannot claim it too. Give one of the relationships a different `inverse_filter`."
753
+ end
754
+ end
755
+
756
+ class InvalidPublicId < Base
757
+ def initialize(resource_class, reason)
758
+ @resource_class = resource_class
759
+ @reason = reason
760
+ end
761
+
762
+ def message
763
+ "#{@resource_class.name}: public_id #{@reason}."
764
+ end
765
+ end
766
+
767
+ class PublicIdLeak < Base
768
+ def initialize(resource_class, attribute_name, source_resource_class)
769
+ @resource_class = resource_class
770
+ @attribute_name = attribute_name
771
+ @source_resource_class = source_resource_class
772
+ end
773
+
774
+ def message
775
+ "#{@resource_class.name}: attribute #{@attribute_name.inspect} is readable, but it holds the primary key #{@source_resource_class.name} hides behind its public id. Declare it `only: [:filterable]` or drop it."
776
+ end
777
+ end
778
+
743
779
  class UnselectedForeignKey < Base
744
780
  def initialize(resource_class, sideload, model)
745
781
  @resource_class = resource_class
@@ -26,6 +26,7 @@ module Graphiti
26
26
  @action = parse_action(action)
27
27
  @entity_map = Concurrent::Map.new if parents.empty?
28
28
  @association_owners = Concurrent::Map.new if parents.empty?
29
+ @public_id_maps = Concurrent::Map.new if parents.empty?
29
30
  end
30
31
 
31
32
  def association?
@@ -45,6 +46,12 @@ module Graphiti
45
46
  @association_owners
46
47
  end
47
48
 
49
+ def public_id_maps
50
+ return root.public_id_maps unless root == self
51
+
52
+ @public_id_maps
53
+ end
54
+
48
55
  # Deduplication exists for a row two include paths both resolve, so a
49
56
  # resource class sitting at one node has nothing to collide with.
50
57
  def repeated_resource_classes
@@ -108,7 +108,7 @@ module Graphiti
108
108
  resource.class.config[:attributes][:id][:writable] == false
109
109
 
110
110
  begin
111
- attributes[key] = resource.typecast(key, value, :writable)
111
+ attributes[key] = typecast_attribute(resource, key, value)
112
112
  rescue Graphiti::Errors::UnknownAttribute
113
113
  @errors.add(fully_qualified_key(key, payload_path), :unknown_attribute)
114
114
  rescue Graphiti::Errors::InvalidAttributeAccess
@@ -120,6 +120,15 @@ module Graphiti
120
120
  end
121
121
  end
122
122
 
123
+ # A writable foreign key arrives as the public id of the record it names, and is decoded when assigned.
124
+ def typecast_attribute(resource, key, value)
125
+ source = (key == :id) ? nil : resource.class.public_id_source_for(key)
126
+ return resource.typecast(key, value, :writable) unless source
127
+
128
+ resource.get_attr!(key, :writable, request: true)
129
+ source.new.typecast(:id, value, :filterable)
130
+ end
131
+
123
132
  def normalized_params(raw_params)
124
133
  normalized = raw_params
125
134
  if normalized.respond_to?(:to_unsafe_h)
@@ -63,7 +63,27 @@ module Graphiti
63
63
 
64
64
  SETTINGS = SETTING_GROUPS.values.reduce(:merge).freeze # :nodoc:
65
65
 
66
+ DSL_SETTINGS = [:adapter, :base_url, :endpoint_namespace, :model, :remote, :remote_base_url, :type, :polymorphic, :polymorphic_child, :serializer, :graphql_entrypoint, *SETTINGS.keys].freeze # :nodoc:
67
+
68
+ UNSET = Object.new.freeze # :nodoc:
69
+
70
+ module DslReaders # :nodoc:
71
+ # Real methods with a default, not define_method with a splat: these readers run per attribute per record.
72
+ DSL_SETTINGS.each do |name|
73
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
74
+ def #{name}(value = UNSET)
75
+ value.equal?(UNSET) ? super() : (self.#{name} = value)
76
+ end
77
+ RUBY
78
+ end
79
+ end
80
+
81
+ # Rails 7 class_attribute writers redefine the reader on the receiving class itself, ahead of anything prepended on Resource.
82
+ READERS_REDEFINED_PER_CLASS = ActiveSupport::VERSION::MAJOR < 8 # :nodoc:
83
+
66
84
  module Overrides
85
+ include DslReaders
86
+
67
87
  SETTINGS.each_pair do |name, setting|
68
88
  next unless setting[:values]
69
89
 
@@ -113,6 +133,7 @@ module Graphiti
113
133
  # The .stat call stores a proc based on adapter
114
134
  # So if we assign a new adapter, reconfigure
115
135
  def adapter=(val)
136
+ val = Adapters.const_get(Adapters.constants.find { |name| name.to_s.underscore == val.to_s }) if val.is_a?(Symbol)
116
137
  super
117
138
  stat total: [:count]
118
139
  end
@@ -129,8 +150,16 @@ module Graphiti
129
150
  }
130
151
  end
131
152
 
132
- def model
133
- klass = super
153
+ def model=(val)
154
+ super
155
+ apply_public_id if publishes_public_id?
156
+ config[:sideloads].each_value { |sideload| sideload.register_public_id_source if eagerly_apply_sideload?(sideload) }
157
+ end
158
+
159
+ def model(value = UNSET)
160
+ return public_send(:model=, value) unless value.equal?(UNSET)
161
+
162
+ klass = super()
134
163
  unless klass || abstract_class?
135
164
  if (klass = infer_model)
136
165
  self.model = klass
@@ -168,6 +197,7 @@ module Graphiti
168
197
 
169
198
  def self.inherited(klass)
170
199
  super
200
+ klass.singleton_class.prepend(DslReaders) if READERS_REDEFINED_PER_CLASS
171
201
  klass.config = Util::Hash.deep_dup(config)
172
202
  klass.adapter ||= Adapters::Abstract
173
203
  # re-assigning causes a new Class.new
@@ -241,6 +271,96 @@ module Graphiti
241
271
  !!page_cursors
242
272
  end
243
273
 
274
+ def publishes_public_id?
275
+ !!(config[:public_id] || config[:public_id_encode])
276
+ end
277
+
278
+ def public_id_attribute?(name)
279
+ !!config[:public_id] && [:id, :_public_id].include?(name.to_sym)
280
+ end
281
+
282
+ def model_declared?
283
+ !!model
284
+ rescue Errors::ModelNotFound
285
+ false
286
+ end
287
+
288
+ def public_id_for(model)
289
+ if (encode = config[:public_id_encode])
290
+ encode.call(model.send(model_primary_key))
291
+ else
292
+ model.send(config[:public_id])
293
+ end
294
+ end
295
+
296
+ def model_attribute_for(name)
297
+ return name unless publishes_public_id?
298
+
299
+ case name
300
+ when :id then config[:public_id] || model_primary_key
301
+ when :_primary_key then model_primary_key
302
+ when :_public_id then config[:public_id]
303
+ else name
304
+ end
305
+ end
306
+
307
+ def model_primary_key
308
+ if model.respond_to?(:primary_key)
309
+ model.primary_key.to_sym
310
+ else
311
+ :id
312
+ end
313
+ end
314
+
315
+ def inferred_public_id_type
316
+ column = config[:public_id]
317
+ return :string unless column && model_loaded? && model.respond_to?(:type_for_attribute)
318
+
319
+ case model.type_for_attribute(column.to_s).type
320
+ when :integer then :integer
321
+ when :uuid then :uuid
322
+ else :string
323
+ end
324
+ end
325
+
326
+ def model_loaded?
327
+ model
328
+ true
329
+ rescue Errors::ModelNotFound
330
+ false
331
+ end
332
+
333
+ def guard_public_id_leak!(attribute_name)
334
+ return if attribute_name.to_sym == :id
335
+
336
+ attribute = attributes[attribute_name.to_sym]
337
+ return unless attribute && attribute[:readable] && !attribute[:proc]
338
+
339
+ source = public_id_source_for(attribute_name)
340
+ raise Errors::PublicIdLeak.new(self, attribute_name, source) if source
341
+ end
342
+
343
+ # A custom block owns its value, so the generated link can only carry a public id if the block asked to decode it.
344
+ def filter_accepts_public_ids?(filter_name)
345
+ filter = filters[filter_name.to_sym]
346
+ return false unless filter
347
+
348
+ filter[:operators][:eq].nil? || Array(filter[:operators_taking_primary_keys]).include?(:eq)
349
+ end
350
+
351
+ def public_id_source_for(filter_name)
352
+ return unless Graphiti.public_ids_declared?
353
+
354
+ name = filter_name.to_sym
355
+ return self if name == :id && config[:public_id_decode]
356
+
357
+ source = Graphiti.public_id_sources[self, name]
358
+ return source if source
359
+
360
+ sideload = sideloads.values.find { |candidate| candidate.type == :belongs_to && candidate.foreign_key == name }
361
+ sideload.resource_class if sideload&.primary_key_filter == :_primary_key
362
+ end
363
+
244
364
  def get_attr!(name, flag, opts = {})
245
365
  opts[:raise_error] = true
246
366
  get_attr(name, flag, opts)
@@ -253,11 +373,11 @@ module Graphiti
253
373
  end
254
374
 
255
375
  def abstract_class?
256
- !!abstract_class
376
+ !!@abstract_class
257
377
  end
258
378
 
259
379
  def abstract_class
260
- @abstract_class
380
+ self.abstract_class = true
261
381
  end
262
382
 
263
383
  def abstract_class=(val)
@@ -16,6 +16,7 @@ module Graphiti
16
16
 
17
17
  aliases = [name, opts[:aliases]].flatten.compact
18
18
  operators = FilterOperators.build(self, att[:type], opts, &blk)
19
+ operators = operators.merge(custom_operators(name)) if opts[:via_attribute_dsl]
19
20
 
20
21
  case Graphiti::Types[att[:type]][:canonical_name]
21
22
  when :boolean
@@ -39,7 +40,9 @@ module Graphiti
39
40
  dependencies: opts[:dependent],
40
41
  required: required,
41
42
  schema: schema,
43
+ internal: !!opts[:internal],
42
44
  operators: operators.to_hash,
45
+ operators_taking_primary_keys: operators_taking_primary_keys(operators.to_hash),
43
46
  blanks: blanks_for(name, opts)
44
47
  }
45
48
  elsif (type = args[0])
@@ -131,7 +134,9 @@ module Graphiti
131
134
  attribute_option(options, :schema, true)
132
135
  options[:type] = type
133
136
  options[:proc] = blk
137
+ options[:proc] ||= public_id_proc if name == :id && publishes_public_id?
134
138
  config[:attributes][name] = options
139
+ guard_public_id_leak!(name)
135
140
  apply_attributes_to_serializer
136
141
  options[:sortable] ? sort(name) : config[:sorts].delete(name)
137
142
 
@@ -142,6 +147,44 @@ module Graphiti
142
147
  end
143
148
  end
144
149
 
150
+ def public_id(name = nil, type = nil, &blk)
151
+ raise Errors::InvalidPublicId.new(self, "takes a column name or a block, not both") if name && blk
152
+ raise Errors::InvalidPublicId.new(self, "needs a column name or a block") unless name || blk
153
+
154
+ if blk
155
+ block = Util::PublicIdBlock.new
156
+ block.instance_eval(&blk)
157
+ raise Errors::InvalidPublicId.new(self, "block must define both encode and decode") unless block.encoder && block.decoder
158
+ config[:public_id_encode] = block.encoder
159
+ config[:public_id_decode] = block.decoder
160
+ end
161
+ config[:public_id] = name
162
+ config[:public_id_type] = type
163
+ Graphiti.public_ids_declared = true
164
+ apply_public_id
165
+ end
166
+
167
+ def operators_taking_primary_keys(operators)
168
+ operators.select { |_, block| block && takes_primary_keys?(block) }.keys
169
+ end
170
+
171
+ def takes_primary_keys?(block)
172
+ block.parameters.any? { |kind, name| kind == :keyrest || (name == :primary_keys && %i[key keyreq].include?(kind)) }
173
+ end
174
+
175
+ # The hidden filter only ever sees keys Graphiti read off models, so its type is never used to cast anything.
176
+ def public_id_proc
177
+ proc { @resource.class.public_id_for(@object) }
178
+ end
179
+
180
+ def apply_public_id
181
+ attribute :id, config[:public_id_type] || inferred_public_id_type, &public_id_proc
182
+ attribute :_primary_key, :integer_id, only: [:filterable], schema: false
183
+ filter :_primary_key, schema: false, internal: true
184
+ attribute :_public_id, config[:public_id_type] || inferred_public_id_type, only: [:filterable], schema: false
185
+ filter :_public_id, schema: false, internal: true
186
+ end
187
+
145
188
  def extra_attribute(name, type, options = {}, &blk)
146
189
  raise Errors::TypeNotFound.new(self, name, type) unless Types[type]
147
190
  defaults = {
@@ -175,13 +218,18 @@ module Graphiti
175
218
  attributes.merge(extra_attributes)
176
219
  end
177
220
 
221
+ # An abstract resource has no serializer until a concrete subclass is given one.
178
222
  def apply_attributes_to_serializer
223
+ return unless serializer
224
+
179
225
  serializer.type(type)
180
226
  Util::SerializerAttributes.new(self, attributes).apply
181
227
  end
182
228
  private :apply_attributes_to_serializer
183
229
 
184
230
  def apply_extra_attributes_to_serializer
231
+ return unless serializer
232
+
185
233
  Util::SerializerAttributes.new(self, extra_attributes, true).apply
186
234
  end
187
235
 
@@ -206,6 +254,12 @@ module Graphiti
206
254
  end
207
255
  private :relationship_option
208
256
 
257
+ # Declaring an attribute regenerates its filter, which must not silently outrank a filter declared by hand.
258
+ def custom_operators(name)
259
+ (config[:filters][name]&.[](:operators) || {}).compact
260
+ end
261
+ private :custom_operators
262
+
209
263
  def blanks_for(name, opts)
210
264
  if opts.key?(:deny_empty)
211
265
  DEPRECATOR.warn("The deny_empty: filter option is deprecated. Use blanks: :rejected.")
@@ -27,6 +27,40 @@ module Graphiti
27
27
  _find(params, base_scope)
28
28
  end
29
29
 
30
+ # @api private
31
+ def public_ids_by(filter_name, keys)
32
+ encode = config[:public_id_encode]
33
+ return keys.to_h { |key| [key, encode.call(key)] } if encode && filter_name == :_primary_key
34
+
35
+ attribute = model_attribute_for(filter_name)
36
+ value = (filter_name == :_primary_key) ? Util::InternalParam.new(keys) : keys.join(",")
37
+ translate_ids(filter_name => value).each_with_object({}) do |record, map|
38
+ map[record.send(attribute)] = public_id_for(record)
39
+ end
40
+ end
41
+
42
+ # @api private
43
+ # Sqids decodes any string in its alphabet, so "42" yields some unrelated record.
44
+ # A decode only counts if encoding the result gives back the string that was sent.
45
+ def decode_encoded_id(public_id)
46
+ primary_key = config[:public_id_decode].call(public_id)
47
+ return if primary_key.nil?
48
+
49
+ primary_key if config[:public_id_encode].call(primary_key) == public_id
50
+ end
51
+
52
+ def decode_public_id(public_id)
53
+ decode_public_ids([public_id]).first
54
+ end
55
+
56
+ def decode_public_ids(public_ids)
57
+ if config[:public_id_decode]
58
+ return public_ids.map { |public_id| decode_encoded_id(public_id) }.compact
59
+ end
60
+
61
+ translate_ids(_public_id: Util::InternalParam.new(public_ids)).map { |record| record.send(model_primary_key) }
62
+ end
63
+
30
64
  # @api private
31
65
  def _find(params = {}, base_scope = nil)
32
66
  guard_nil_id!(params[:data])
@@ -79,6 +113,11 @@ module Graphiti
79
113
  {cache: @cache_resource, cache_expires_in: @cache_expires_in, cache_tag: @cache_tag}
80
114
  end
81
115
 
116
+ def translate_ids(filter)
117
+ opts = {default_paginate: false, bypass_required_filters: true, bypass_default_filters: true, translating_public_ids: true}
118
+ _all({filter: filter}, opts, nil).data
119
+ end
120
+
82
121
  def validate_request!(params)
83
122
  return if Graphiti.context[:graphql] || !validate_requests?
84
123