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.
- checksums.yaml +4 -4
- data/.github/workflows/docs.yml +7 -0
- data/CHANGELOG.md +13 -0
- data/bin/cut-docs-version +24 -0
- data/lib/generators/graphiti/generator_mixin.rb +1 -1
- data/lib/generators/graphiti/templates/application_resource.rb.erb +5 -5
- data/lib/graphiti/adapters/abstract.rb +8 -0
- data/lib/graphiti/adapters/active_record.rb +8 -0
- data/lib/graphiti/adapters/persistence/associations.rb +6 -1
- data/lib/graphiti/audit/report.rb +3 -1
- data/lib/graphiti/audit.rb +14 -1
- data/lib/graphiti/errors.rb +36 -0
- data/lib/graphiti/query.rb +7 -0
- data/lib/graphiti/request_validators/validator.rb +10 -1
- data/lib/graphiti/resource/configuration.rb +124 -4
- data/lib/graphiti/resource/dsl.rb +47 -0
- data/lib/graphiti/resource/interface.rb +39 -0
- data/lib/graphiti/resource/persistence.rb +29 -0
- data/lib/graphiti/resource/sideloading.rb +2 -0
- data/lib/graphiti/resource.rb +4 -0
- data/lib/graphiti/resource_proxy.rb +6 -3
- data/lib/graphiti/runner.rb +3 -1
- data/lib/graphiti/schema.rb +4 -0
- data/lib/graphiti/schema_diff.rb +5 -0
- data/lib/graphiti/scope.rb +8 -0
- data/lib/graphiti/scoping/default_filter.rb +2 -0
- data/lib/graphiti/scoping/filter.rb +79 -20
- data/lib/graphiti/scoping/sort.rb +1 -1
- data/lib/graphiti/sideload/belongs_to.rb +52 -8
- data/lib/graphiti/sideload/has_many.rb +33 -2
- data/lib/graphiti/sideload/many_to_many.rb +3 -3
- data/lib/graphiti/sideload.rb +25 -1
- data/lib/graphiti/stats/payload.rb +20 -3
- data/lib/graphiti/util/internal_param.rb +16 -0
- data/lib/graphiti/util/link.rb +10 -3
- data/lib/graphiti/util/persistence.rb +1 -1
- data/lib/graphiti/util/public_id_block.rb +15 -0
- data/lib/graphiti/util/public_id_map.rb +34 -0
- data/lib/graphiti/util/public_id_sources.rb +27 -0
- data/lib/graphiti/util/serializer_relationships.rb +5 -3
- data/lib/graphiti/version.rb +1 -1
- data/lib/graphiti.rb +16 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd25841a045956cda400bc9fd72f0eaac514cd7b0c393145607383bdfb4f9628
|
|
4
|
+
data.tar.gz: bb457041c8ed31b7f527614cf241fb43e626f8abc465d2ee5fca851081be94c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cedef768a22bd9a6058a1d0c527c60c34478b6aa3091c31c8524a1feb7dad33bd5912c4beddf5d8b6f49f964a1ff32b1eff8c778cc12a55a3e420c8914127923
|
|
7
|
+
data.tar.gz: 8638ac3f7ea8702df0ac0f475cfd5c820d477921e5a4af85b6dd5b845a507d3a81914ffde73ecfeaa86fc4a4cf992044399ae8482d0339f0dc403fef0f301122
|
data/.github/workflows/docs.yml
CHANGED
|
@@ -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, "
|
|
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
|
-
|
|
6
|
-
|
|
5
|
+
abstract_class
|
|
6
|
+
adapter :active_record
|
|
7
7
|
<%- unless omit_comments? -%>
|
|
8
8
|
# Or follow config/environments:
|
|
9
|
-
#
|
|
9
|
+
# base_url ActionDispatch::Http::URL.url_for(Rails.application.routes.default_url_options)
|
|
10
10
|
<%- end -%>
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
data/lib/graphiti/audit.rb
CHANGED
|
@@ -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
|
data/lib/graphiti/errors.rb
CHANGED
|
@@ -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
|
data/lib/graphiti/query.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
376
|
+
!!@abstract_class
|
|
257
377
|
end
|
|
258
378
|
|
|
259
379
|
def abstract_class
|
|
260
|
-
|
|
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
|
|