jpie 2.1.0 → 2.1.3
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/.claude/skills/root-cause-analysis/SKILL.md +2 -0
- data/.cursor/agents/systematic-debugging.md +2 -0
- data/Gemfile.lock +1 -1
- data/lib/json_api/controllers/concerns/resource_actions/filter_validation.rb +5 -16
- data/lib/json_api/resources/resource_loader.rb +43 -34
- data/lib/json_api/support/concerns/condition_building.rb +13 -2
- data/lib/json_api/support/concerns/nested_filters.rb +2 -30
- data/lib/json_api/support/concerns/regular_filters.rb +1 -29
- data/lib/json_api/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff291316aa0dff52e52421edfb945d6c450224761af9552da4cf80c5d5939385
|
|
4
|
+
data.tar.gz: d20184a0ff94d93da07a167088fdd5ea12346698e5324554a9ee5b4e232784f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ef0a9f42b050d26a21545044b985d5deb9cd3417e073e5ec26d9fc462f954c687c547fd850f1a93b3bd7567d0d1b24418f289b61373792a5f6b7005af90885c
|
|
7
|
+
data.tar.gz: fa80175057fb038cc79ec2c3ee5fa01ac33758c72fd4f618369cd245f8dd3a4c2a2c8c7612ed2ff49f49374514aafb27abab55c801866821a92ae502582c2970
|
|
@@ -20,6 +20,8 @@ Do NOT use when the cause is already known and verified, or for routine implemen
|
|
|
20
20
|
|
|
21
21
|
**Find root cause before attempting fixes.** Quick patches mask underlying issues. If you are trying fixes without understanding why they might work, you are guessing—stop and investigate.
|
|
22
22
|
|
|
23
|
+
Do not change code just to pass specs, and do not change specs just to make them pass. Specs must test real functionality; both code and specs should be fixed so that they correctly reflect and verify the intended behaviour.
|
|
24
|
+
|
|
23
25
|
## Problem definition (observable symptom only)
|
|
24
26
|
|
|
25
27
|
Define the problem in terms of **what is observed**, not assumed cause:
|
|
@@ -9,6 +9,8 @@ You are a systematic debugging specialist for the json_api gem (JPie). Your only
|
|
|
9
9
|
|
|
10
10
|
**ALWAYS find root cause before attempting fixes.** Symptom fixes are failure. Quick patches mask underlying issues. If you are trying fixes without understanding why they might work, you are guessing—stop and investigate.
|
|
11
11
|
|
|
12
|
+
Do not recommend changing code just to pass specs, or changing specs just to make them pass. Specs must test real functionality; recommend fixes that make code and specs correctly reflect and verify intended behaviour.
|
|
13
|
+
|
|
12
14
|
## Four phases (complete in order)
|
|
13
15
|
|
|
14
16
|
### Phase 1: Investigation
|
data/Gemfile.lock
CHANGED
|
@@ -21,26 +21,15 @@ module JSONAPI
|
|
|
21
21
|
check_filter_parts(parts, @resource_class, model_class)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def check_filter_parts(parts, res_cls, mod_cls
|
|
24
|
+
def check_filter_parts(parts, res_cls, mod_cls)
|
|
25
25
|
return false if parts.empty?
|
|
26
|
-
return single_filter_valid?(parts.first, res_cls
|
|
26
|
+
return single_filter_valid?(parts.first, res_cls) if parts.length == 1
|
|
27
27
|
|
|
28
28
|
check_nested_filter(parts, res_cls, mod_cls)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def single_filter_valid?(name, res_cls
|
|
32
|
-
|
|
33
|
-
return false unless allow_related
|
|
34
|
-
|
|
35
|
-
related_column_valid?(name, mod_cls)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def related_column_valid?(name, mod_cls)
|
|
39
|
-
col = parse_column_filter(name)
|
|
40
|
-
return true if col && mod_cls.column_for_attribute(col[:column])
|
|
41
|
-
return true if mod_cls.column_names.include?(name)
|
|
42
|
-
|
|
43
|
-
mod_cls.respond_to?(name.to_sym)
|
|
31
|
+
def single_filter_valid?(name, res_cls)
|
|
32
|
+
res_cls.permitted_filters.map(&:to_s).include?(name)
|
|
44
33
|
end
|
|
45
34
|
|
|
46
35
|
def check_nested_filter(parts, res_cls, mod_cls)
|
|
@@ -59,7 +48,7 @@ module JSONAPI
|
|
|
59
48
|
rel_res = JSONAPI::Resource.resource_for_model(rel_mod)
|
|
60
49
|
return false unless rel_res
|
|
61
50
|
|
|
62
|
-
check_filter_parts(parts, rel_res, rel_mod
|
|
51
|
+
check_filter_parts(parts, rel_res, rel_mod)
|
|
63
52
|
end
|
|
64
53
|
|
|
65
54
|
def filter_rel_allowed?(res_cls, rel)
|
|
@@ -17,25 +17,27 @@ module JSONAPI
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def self.find(resource_type, namespace: nil)
|
|
20
|
-
|
|
20
|
+
candidates = []
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
# Namespaced resource, e.g. "widgets" with namespace "api/v1" → API::V1::WidgetResource
|
|
23
|
+
candidates << build_resource_class_name(resource_type, namespace) if namespace
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
# Flat resource, e.g. "widgets" → WidgetResource
|
|
26
|
+
if !namespace || JSONAPI.configuration.namespace_fallback
|
|
27
|
+
candidates << build_resource_class_name(resource_type,
|
|
28
|
+
nil,)
|
|
29
|
+
end
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
resolve(candidates) || raise(MissingResourceClass.new(resource_type, namespace:))
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
def self.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
def self.find_for_model(model_class, namespace: nil)
|
|
35
|
+
return ActiveStorageBlobResource if active_storage_blob?(model_class)
|
|
36
|
+
|
|
37
|
+
effective_namespace = namespace || extract_namespace_from_model(model_class)
|
|
38
|
+
candidates = model_candidates(model_class, effective_namespace)
|
|
39
|
+
|
|
40
|
+
resolve(candidates) || raise(MissingResourceClass.new(model_class.name, namespace: effective_namespace))
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def self.build_resource_class_name(resource_type, namespace)
|
|
@@ -45,35 +47,42 @@ module JSONAPI
|
|
|
45
47
|
"#{namespace.to_s.camelize}::#{base}"
|
|
46
48
|
end
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
# Builds an ordered list of candidate class names for a model, including
|
|
51
|
+
# STI base class candidates as a fallback.
|
|
52
|
+
def self.model_candidates(model_class, namespace)
|
|
53
|
+
candidates = candidates_for_class(model_class, namespace)
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
if sti_subclass?(model_class)
|
|
56
|
+
base = model_class.base_class
|
|
57
|
+
base_ns = extract_namespace_from_model(base) || namespace
|
|
58
|
+
candidates.concat(candidates_for_class(base, base_ns))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
candidates
|
|
52
62
|
end
|
|
53
63
|
|
|
54
|
-
def self.
|
|
55
|
-
effective_namespace = namespace || extract_namespace_from_model(model_class)
|
|
64
|
+
def self.candidates_for_class(model_class, namespace)
|
|
56
65
|
resource_type = model_class.name.demodulize.underscore.pluralize
|
|
66
|
+
candidates = []
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
try_flat_full_name_resource(model_class) || find_base_class_resource(model_class, effective_namespace)
|
|
61
|
-
end
|
|
68
|
+
# Namespaced resource matching the model's namespace, e.g. API::V1::Widget → API::V1::WidgetResource
|
|
69
|
+
candidates << build_resource_class_name(resource_type, namespace) if namespace
|
|
62
70
|
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
# Flat-combined name for namespaced models, e.g. Ticket::User → TicketUserResource
|
|
72
|
+
candidates << "#{model_class.name.gsub("::", "")}Resource" if model_class.name.include?("::")
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
nil
|
|
69
|
-
end
|
|
74
|
+
# Standard flat resource using demodulized model name, e.g. User → UserResource
|
|
75
|
+
candidates << build_resource_class_name(resource_type, nil)
|
|
70
76
|
|
|
71
|
-
|
|
72
|
-
|
|
77
|
+
candidates
|
|
78
|
+
end
|
|
73
79
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
def self.resolve(candidates)
|
|
81
|
+
candidates.each do |name|
|
|
82
|
+
klass = name.safe_constantize
|
|
83
|
+
return klass if klass
|
|
84
|
+
end
|
|
85
|
+
nil
|
|
77
86
|
end
|
|
78
87
|
|
|
79
88
|
def self.active_storage_blob?(model_class)
|
|
@@ -13,8 +13,6 @@ module JSONAPI
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def normalize_filter_value_for_model(model, column, raw_value)
|
|
16
|
-
return nil unless column
|
|
17
|
-
|
|
18
16
|
value = raw_value.is_a?(Array) ? raw_value.first : raw_value
|
|
19
17
|
return nil if value.nil?
|
|
20
18
|
|
|
@@ -39,6 +37,19 @@ module JSONAPI
|
|
|
39
37
|
lower_attr.matches(pattern.downcase)
|
|
40
38
|
end
|
|
41
39
|
|
|
40
|
+
def apply_column_filter(scope, model, filter_name, raw_value)
|
|
41
|
+
column_filter = parse_column_filter(filter_name)
|
|
42
|
+
return nil unless column_filter
|
|
43
|
+
return nil unless model.column_names.include?(column_filter[:column])
|
|
44
|
+
|
|
45
|
+
column = model.column_for_attribute(column_filter[:column])
|
|
46
|
+
value = normalize_filter_value_for_model(model, column, raw_value)
|
|
47
|
+
return nil unless value
|
|
48
|
+
|
|
49
|
+
condition = build_condition(model, column, value, column_filter[:operator])
|
|
50
|
+
condition ? apply_condition(scope, condition) : nil
|
|
51
|
+
end
|
|
52
|
+
|
|
42
53
|
def apply_condition(scope, condition)
|
|
43
54
|
scope.where(condition)
|
|
44
55
|
end
|
|
@@ -81,40 +81,12 @@ module JSONAPI
|
|
|
81
81
|
def apply_filter_on_model(scope, target_model, target_resource, filter_name, filter_value)
|
|
82
82
|
return scope if empty_filter_value?(filter_value)
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
apply_direct_column_filter(scope, target_model, filter_name, filter_value) ||
|
|
84
|
+
apply_column_filter(scope, target_model, filter_name, filter_value) ||
|
|
86
85
|
apply_scope_method_filter(scope, target_model, target_resource, filter_name, filter_value) ||
|
|
87
86
|
scope
|
|
88
87
|
end
|
|
89
88
|
|
|
90
|
-
def
|
|
91
|
-
column_filter = parse_column_filter(filter_name)
|
|
92
|
-
return nil unless column_filter
|
|
93
|
-
|
|
94
|
-
column = target_model.column_for_attribute(column_filter[:column])
|
|
95
|
-
return nil unless column
|
|
96
|
-
|
|
97
|
-
value = normalize_filter_value_for_model(target_model, column, filter_value)
|
|
98
|
-
return nil unless value
|
|
99
|
-
|
|
100
|
-
condition = build_condition(target_model, column, value, column_filter[:operator])
|
|
101
|
-
condition ? apply_condition(scope, condition) : nil
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def apply_direct_column_filter(scope, target_model, filter_name, filter_value)
|
|
105
|
-
return nil unless target_model.column_names.include?(filter_name)
|
|
106
|
-
|
|
107
|
-
scope.where(target_model.table_name => { filter_name => filter_value })
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def apply_scope_method_filter(scope, target_model, target_resource, filter_name, filter_value)
|
|
111
|
-
if target_model.respond_to?(filter_name.to_sym)
|
|
112
|
-
return try_scope_method(scope, target_model, filter_name,
|
|
113
|
-
filter_value,)
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
return nil unless target_resource
|
|
117
|
-
return nil unless target_resource.permitted_filters.map(&:to_s).include?(filter_name)
|
|
89
|
+
def apply_scope_method_filter(scope, target_model, _target_resource, filter_name, filter_value)
|
|
118
90
|
return nil unless target_model.respond_to?(filter_name.to_sym)
|
|
119
91
|
|
|
120
92
|
try_scope_method(scope, target_model, filter_name, filter_value)
|
|
@@ -19,36 +19,8 @@ module JSONAPI
|
|
|
19
19
|
def apply_regular_filter(scope, filter_name, filter_value)
|
|
20
20
|
return scope if empty_filter_value?(filter_value)
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
if column_filter
|
|
24
|
-
apply_column_filter(scope, column_filter, filter_value)
|
|
25
|
-
else
|
|
22
|
+
apply_column_filter(scope, model_class, filter_name, filter_value) ||
|
|
26
23
|
apply_scope_fallback(scope, filter_name, filter_value)
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def apply_column_filter(scope, column_filter, raw_value)
|
|
31
|
-
condition = build_column_condition(column_filter, raw_value)
|
|
32
|
-
condition ? apply_condition(scope, condition) : scope
|
|
33
|
-
rescue StandardError => e
|
|
34
|
-
log_filter_error(column_filter, column_filter[:operator], e)
|
|
35
|
-
scope
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def build_column_condition(column_filter, raw_value)
|
|
39
|
-
column = model_class.column_for_attribute(column_filter[:column])
|
|
40
|
-
return nil unless column
|
|
41
|
-
|
|
42
|
-
value = normalize_filter_value_for_model(model_class, column, raw_value)
|
|
43
|
-
return nil if value.nil?
|
|
44
|
-
|
|
45
|
-
build_condition(model_class, column, value, column_filter[:operator])
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
def log_filter_error(column_filter, operator, error)
|
|
49
|
-
return unless defined?(Rails.logger)
|
|
50
|
-
|
|
51
|
-
Rails.logger.warn("Filter error for #{column_filter[:column]}_#{operator}: #{error.class} - #{error.message}")
|
|
52
24
|
end
|
|
53
25
|
|
|
54
26
|
def apply_scope_fallback(scope, filter_name, filter_value)
|
data/lib/json_api/version.rb
CHANGED