graphiti 2.0.2 → 2.1.0

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/docs.yml +7 -0
  3. data/CHANGELOG.md +13 -0
  4. data/bin/cut-docs-version +24 -0
  5. data/lib/generators/graphiti/generator_mixin.rb +1 -1
  6. data/lib/generators/graphiti/templates/application_resource.rb.erb +5 -5
  7. data/lib/graphiti/adapters/abstract.rb +8 -0
  8. data/lib/graphiti/adapters/active_record.rb +8 -0
  9. data/lib/graphiti/adapters/persistence/associations.rb +6 -1
  10. data/lib/graphiti/audit/report.rb +3 -1
  11. data/lib/graphiti/audit.rb +14 -1
  12. data/lib/graphiti/errors.rb +36 -0
  13. data/lib/graphiti/query.rb +7 -0
  14. data/lib/graphiti/request_validators/validator.rb +10 -1
  15. data/lib/graphiti/resource/configuration.rb +124 -4
  16. data/lib/graphiti/resource/dsl.rb +47 -0
  17. data/lib/graphiti/resource/interface.rb +39 -0
  18. data/lib/graphiti/resource/persistence.rb +29 -0
  19. data/lib/graphiti/resource/sideloading.rb +2 -0
  20. data/lib/graphiti/resource.rb +4 -0
  21. data/lib/graphiti/resource_proxy.rb +6 -3
  22. data/lib/graphiti/runner.rb +3 -1
  23. data/lib/graphiti/schema.rb +4 -0
  24. data/lib/graphiti/schema_diff.rb +5 -0
  25. data/lib/graphiti/scope.rb +8 -0
  26. data/lib/graphiti/scoping/default_filter.rb +2 -0
  27. data/lib/graphiti/scoping/filter.rb +79 -20
  28. data/lib/graphiti/scoping/sort.rb +1 -1
  29. data/lib/graphiti/sideload/belongs_to.rb +52 -8
  30. data/lib/graphiti/sideload/has_many.rb +33 -2
  31. data/lib/graphiti/sideload/many_to_many.rb +3 -3
  32. data/lib/graphiti/sideload.rb +25 -1
  33. data/lib/graphiti/stats/payload.rb +20 -3
  34. data/lib/graphiti/util/internal_param.rb +16 -0
  35. data/lib/graphiti/util/link.rb +10 -3
  36. data/lib/graphiti/util/persistence.rb +1 -1
  37. data/lib/graphiti/util/public_id_block.rb +15 -0
  38. data/lib/graphiti/util/public_id_map.rb +34 -0
  39. data/lib/graphiti/util/public_id_sources.rb +27 -0
  40. data/lib/graphiti/util/serializer_relationships.rb +5 -3
  41. data/lib/graphiti/version.rb +1 -1
  42. data/lib/graphiti.rb +16 -0
  43. 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: dd25841a045956cda400bc9fd72f0eaac514cd7b0c393145607383bdfb4f9628
4
+ data.tar.gz: bb457041c8ed31b7f527614cf241fb43e626f8abc465d2ee5fca851081be94c1
5
5
  SHA512:
6
- metadata.gz: b8c3fcadbaba93c23bdf505937443432abc2e0b120a2baadd76279c20f7ba0382798a416dd83832e4ad0e9697e5cb20570df93c0747eea5c832782a1cf442865
7
- data.tar.gz: c3f790bed7b1cba2802e5a10b9c9f51c53ccc44e9da9823956266361f80a4af0df790d59ebe951f97768b4ba80da7a002ae919cc739225ed22cec8084e4292ce
6
+ metadata.gz: cedef768a22bd9a6058a1d0c527c60c34478b6aa3091c31c8524a1feb7dad33bd5912c4beddf5d8b6f49f964a1ff32b1eff8c778cc12a55a3e420c8914127923
7
+ data.tar.gz: 8638ac3f7ea8702df0ac0f475cfd5c820d477921e5a4af85b6dd5b845a507d3a81914ffde73ecfeaa86fc4a4cf992044399ae8482d0339f0dc403fef0f301122
@@ -12,6 +12,8 @@ on:
12
12
  - 'docs/**'
13
13
  - 'website/**'
14
14
  - '.github/workflows/docs.yml'
15
+ release:
16
+ types: [published]
15
17
  workflow_dispatch: {}
16
18
 
17
19
  # One static group: deploys from different branches force-push the same
@@ -39,6 +41,11 @@ jobs:
39
41
  working-directory: website
40
42
  run: npm ci
41
43
 
44
+ - name: Find the latest release
45
+ run: |
46
+ git fetch --tags --quiet
47
+ echo "GRAPHITI_RELEASE=$(git tag --list 'v2*' --sort=-v:refname | head -1 | sed 's/^v//')" >> "$GITHUB_ENV"
48
+
42
49
  - name: Build
43
50
  working-directory: website
44
51
  run: npm run build
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  graphiti changelog
2
2
 
3
+ # [2.1.0](https://github.com/graphiti-api/graphiti/compare/v2.0.2...v2.1.0) (2026-09-04)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 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)
9
+
10
+
11
+ ### Features
12
+
13
+ * declare resource settings in the DSL form (adapter :active_record, model Employee), matching `attribute :name` ([14f1722](https://github.com/graphiti-api/graphiti/commit/14f17222770ba8577048775a45c893e85aef7e94))
14
+ * 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)
15
+
3
16
  ## [2.0.2](https://github.com/graphiti-api/graphiti/compare/v2.0.1...v2.0.2) (2026-09-04)
4
17
 
5
18
 
@@ -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)
@@ -39,7 +39,9 @@ module Graphiti
39
39
  dependencies: opts[:dependent],
40
40
  required: required,
41
41
  schema: schema,
42
+ internal: !!opts[:internal],
42
43
  operators: operators.to_hash,
44
+ operators_taking_primary_keys: operators_taking_primary_keys(operators.to_hash),
43
45
  blanks: blanks_for(name, opts)
44
46
  }
45
47
  elsif (type = args[0])
@@ -131,7 +133,9 @@ module Graphiti
131
133
  attribute_option(options, :schema, true)
132
134
  options[:type] = type
133
135
  options[:proc] = blk
136
+ options[:proc] ||= public_id_proc if name == :id && publishes_public_id?
134
137
  config[:attributes][name] = options
138
+ guard_public_id_leak!(name)
135
139
  apply_attributes_to_serializer
136
140
  options[:sortable] ? sort(name) : config[:sorts].delete(name)
137
141
 
@@ -142,6 +146,44 @@ module Graphiti
142
146
  end
143
147
  end
144
148
 
149
+ def public_id(name = nil, type = nil, &blk)
150
+ raise Errors::InvalidPublicId.new(self, "takes a column name or a block, not both") if name && blk
151
+ raise Errors::InvalidPublicId.new(self, "needs a column name or a block") unless name || blk
152
+
153
+ if blk
154
+ block = Util::PublicIdBlock.new
155
+ block.instance_eval(&blk)
156
+ raise Errors::InvalidPublicId.new(self, "block must define both encode and decode") unless block.encoder && block.decoder
157
+ config[:public_id_encode] = block.encoder
158
+ config[:public_id_decode] = block.decoder
159
+ end
160
+ config[:public_id] = name
161
+ config[:public_id_type] = type
162
+ Graphiti.public_ids_declared = true
163
+ apply_public_id
164
+ end
165
+
166
+ def operators_taking_primary_keys(operators)
167
+ operators.select { |_, block| block && takes_primary_keys?(block) }.keys
168
+ end
169
+
170
+ def takes_primary_keys?(block)
171
+ block.parameters.any? { |kind, name| kind == :keyrest || (name == :primary_keys && %i[key keyreq].include?(kind)) }
172
+ end
173
+
174
+ # The hidden filter only ever sees keys Graphiti read off models, so its type is never used to cast anything.
175
+ def public_id_proc
176
+ proc { @resource.class.public_id_for(@object) }
177
+ end
178
+
179
+ def apply_public_id
180
+ attribute :id, config[:public_id_type] || inferred_public_id_type, &public_id_proc
181
+ attribute :_primary_key, :integer_id, only: [:filterable], schema: false
182
+ filter :_primary_key, schema: false, internal: true
183
+ attribute :_public_id, config[:public_id_type] || inferred_public_id_type, only: [:filterable], schema: false
184
+ filter :_public_id, schema: false, internal: true
185
+ end
186
+
145
187
  def extra_attribute(name, type, options = {}, &blk)
146
188
  raise Errors::TypeNotFound.new(self, name, type) unless Types[type]
147
189
  defaults = {
@@ -175,13 +217,18 @@ module Graphiti
175
217
  attributes.merge(extra_attributes)
176
218
  end
177
219
 
220
+ # An abstract resource has no serializer until a concrete subclass is given one.
178
221
  def apply_attributes_to_serializer
222
+ return unless serializer
223
+
179
224
  serializer.type(type)
180
225
  Util::SerializerAttributes.new(self, attributes).apply
181
226
  end
182
227
  private :apply_attributes_to_serializer
183
228
 
184
229
  def apply_extra_attributes_to_serializer
230
+ return unless serializer
231
+
185
232
  Util::SerializerAttributes.new(self, extra_attributes, true).apply
186
233
  end
187
234
 
@@ -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
 
@@ -78,8 +78,16 @@ module Graphiti
78
78
  if action_name == :update
79
79
  id = assign_params[:id]
80
80
  assign_params = assign_params.except(:id)
81
+ elsif assign_params.key?(:id) && (public_id = self.class.config[:public_id])
82
+ assign_params = assign_params.except(:id).merge(public_id => assign_params[:id])
83
+ elsif assign_params.key?(:id) && self.class.config[:public_id_decode]
84
+ primary_key = self.class.decode_public_id(assign_params[:id])
85
+ raise Errors::ConflictRequest, unresolvable_id_errors(assign_params) if primary_key.nil?
86
+ assign_params = assign_params.except(:id).merge(self.class.model_primary_key => primary_key)
81
87
  end
82
88
 
89
+ assign_params = decode_foreign_keys(assign_params)
90
+
83
91
  run_callbacks :attributes, action_name, assign_params, meta do |params|
84
92
  model_instance ||= if action_name == :update
85
93
  self.class._find(id: id).data
@@ -123,6 +131,27 @@ module Graphiti
123
131
  model_instance
124
132
  end
125
133
 
134
+ def decode_foreign_keys(assign_params)
135
+ return assign_params unless Graphiti.public_ids_declared?
136
+
137
+ assign_params.each_with_object({}) do |(name, value), decoded|
138
+ source = (name == :id || value.nil?) ? nil : self.class.public_id_source_for(name)
139
+ decoded[name] = if value.is_a?(Util::InternalParam)
140
+ value.value
141
+ elsif source
142
+ source.decode_public_id(value.to_s) or raise Errors::RecordNotFound.new(source.type, value, name)
143
+ else
144
+ value
145
+ end
146
+ end
147
+ end
148
+
149
+ def unresolvable_id_errors(assign_params)
150
+ Util::SimpleErrors.new(assign_params).tap do |errors|
151
+ errors.add("data.id", :unresolvable, message: "does not name a record of this resource")
152
+ end
153
+ end
154
+
126
155
  def update(update_params, meta = nil)
127
156
  model_instance = assigned_model || assign(update_params, meta, :update)
128
157
 
@@ -23,10 +23,12 @@ module Graphiti
23
23
  end
24
24
 
25
25
  def apply_sideload_to_serializer(name)
26
+ config[:sideloads][name].register_public_id_source
26
27
  Util::SerializerRelationships.new(self, config[:sideloads].slice(name)).apply
27
28
  end
28
29
 
29
30
  def apply_sideloads_to_serializer
31
+ config[:sideloads].each_value(&:register_public_id_source)
30
32
  Util::SerializerRelationships.new(self, config[:sideloads]).apply
31
33
  end
32
34