featurevisor 1.0.0 → 2.0.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/LICENSE +1 -1
- data/README.md +84 -6
- data/bin/commands/test.rb +1 -1
- data/lib/featurevisor/bucketer.rb +62 -8
- data/lib/featurevisor/child_instance.rb +43 -1
- data/lib/featurevisor/conditions.rb +63 -34
- data/lib/featurevisor/{logger.rb → diagnostics.rb} +19 -52
- data/lib/featurevisor/evaluate.rb +51 -50
- data/lib/featurevisor/{datafile_reader.rb → evaluation_data_provider.rb} +27 -16
- data/lib/featurevisor/events.rb +2 -2
- data/lib/featurevisor/instance.rb +74 -40
- data/lib/featurevisor/version.rb +1 -1
- data/lib/featurevisor.rb +4 -4
- metadata +15 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7745fb77b8646de51fe54b58c32274829b60b046e6a47d81872e3148107eac6
|
|
4
|
+
data.tar.gz: ef5f69f50e1af01af4b45aece70603a67dc77f3af08da98b10cf6e50dd3d77df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 745e81a4af47bc19e0a0bfe550badafb52e7541a067f461bdbc8b73be259399da5ca3fd519e7458ee0afa3ee6f810e0d7e28446add35ee13e970f25ecc7a0d2d
|
|
7
|
+
data.tar.gz: 44947e427ceb1cf6b87e2e0784e7c77dddcbd8e27677c6794dd885a3a83dec65a4a2a1cec1815198abbe3e47187399700e9ec7e4342fd729428fafb21ab2dadd
|
data/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
The MIT License (MIT)
|
|
2
2
|
|
|
3
|
-
Copyright (c)
|
|
3
|
+
Copyright (c) 2026 Fahad Heylaal (https://fahad19.com)
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
data/README.md
CHANGED
|
@@ -43,6 +43,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles.
|
|
|
43
43
|
- [Registering modules](#registering-modules)
|
|
44
44
|
- [Child instance](#child-instance)
|
|
45
45
|
- [Close](#close)
|
|
46
|
+
- [OpenFeature](#openfeature)
|
|
46
47
|
- [CLI usage](#cli-usage)
|
|
47
48
|
- [Test](#test)
|
|
48
49
|
- [Test against local monorepo's example-1](#test-against-local-monorepos-example-1)
|
|
@@ -88,6 +89,8 @@ f = Featurevisor.create_featurevisor(
|
|
|
88
89
|
|
|
89
90
|
Most applications only need this factory and the returned `Featurevisor::Instance`. Public extension and observability APIs include modules, diagnostics, events, and the datafile structures accepted by the factory.
|
|
90
91
|
|
|
92
|
+
Concurrent evaluations are safe after an instance is configured. Do not mutate or close the same instance concurrently with evaluations. Serialize calls to `set_datafile`, `set_context`, `set_sticky`, `add_module`, `remove_module`, and `close`. Module, event, and diagnostic callbacks must synchronize mutable state that they capture.
|
|
93
|
+
|
|
91
94
|
## Initialization
|
|
92
95
|
|
|
93
96
|
The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly:
|
|
@@ -696,6 +699,8 @@ f.remove_module('my-custom-module')
|
|
|
696
699
|
|
|
697
700
|
## Child instance
|
|
698
701
|
|
|
702
|
+
A child snapshots the parent keys that exist when it is spawned. Child values win for those keys. Parent keys introduced later are still inherited. Calling `close` removes both child-owned listeners and subscriptions delegated to the parent.
|
|
703
|
+
|
|
699
704
|
When dealing with purely client-side applications, it is understandable that there is only one user involved, like in browser or mobile applications.
|
|
700
705
|
|
|
701
706
|
But when using Featurevisor SDK in server-side applications, where a single server instance can handle multiple user requests simultaneously, it is important to isolate the context for each request.
|
|
@@ -721,8 +726,11 @@ Similar to parent SDK, child instances also support several additional methods:
|
|
|
721
726
|
|
|
722
727
|
- `set_context`
|
|
723
728
|
- `set_sticky`
|
|
729
|
+
- `evaluate_flag`
|
|
724
730
|
- `is_enabled`
|
|
731
|
+
- `evaluate_variation`
|
|
725
732
|
- `get_variation`
|
|
733
|
+
- `evaluate_variable`
|
|
726
734
|
- `get_variable`
|
|
727
735
|
- `get_variable_boolean`
|
|
728
736
|
- `get_variable_string`
|
|
@@ -778,7 +786,7 @@ All three commands accept repeatable `--target=<target>` options. `test` builds
|
|
|
778
786
|
|
|
779
787
|
```bash
|
|
780
788
|
$ cd /absolute/path/to/featurevisor-ruby
|
|
781
|
-
$ bundle exec ruby bin/featurevisor test --projectDirectoryPath
|
|
789
|
+
$ bundle exec ruby bin/featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures
|
|
782
790
|
$ make test-example-1
|
|
783
791
|
```
|
|
784
792
|
|
|
@@ -811,30 +819,100 @@ $ bundle exec featurevisor assess-distribution \
|
|
|
811
819
|
--n=1000
|
|
812
820
|
```
|
|
813
821
|
|
|
822
|
+
## OpenFeature
|
|
823
|
+
|
|
824
|
+
The OpenFeature provider is published as a separate gem. This keeps OpenFeature code and dependencies out of applications that only use the Featurevisor SDK.
|
|
825
|
+
|
|
826
|
+
The provider currently requires Ruby 3.4 or newer because that is the minimum version supported by the official OpenFeature Ruby SDK.
|
|
827
|
+
|
|
828
|
+
Install the provider:
|
|
829
|
+
|
|
830
|
+
```ruby
|
|
831
|
+
gem "featurevisor-openfeature", "~> 2.0"
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
It installs the matching `featurevisor` gem and the official `openfeature-sdk` dependency. The provider and base SDK deliberately share the same version, and the provider requires that exact Featurevisor version.
|
|
835
|
+
|
|
836
|
+
```ruby
|
|
837
|
+
require "featurevisor/openfeature_provider"
|
|
838
|
+
|
|
839
|
+
provider = Featurevisor::OpenFeatureProvider.new(
|
|
840
|
+
datafile: datafile_content,
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
OpenFeature::SDK.configure do |config|
|
|
844
|
+
config.set_provider_and_wait(provider)
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
client = OpenFeature::SDK.build_client
|
|
848
|
+
enabled = client.fetch_boolean_value(
|
|
849
|
+
flag_key: "checkout",
|
|
850
|
+
default_value: false,
|
|
851
|
+
evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "user-123"),
|
|
852
|
+
)
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
Use `checkout` for a flag, `checkout:variation` for its variation, and `checkout:title` for its `title` variable. Boolean variables use the boolean resolver. Arrays, hashes, and JSON variables use the object resolver.
|
|
856
|
+
|
|
857
|
+
OpenFeature's targeting key maps to `userId` by default. `targeting_key_field`, `key_separator`, and `variation_key` can customize the mapping.
|
|
858
|
+
|
|
859
|
+
You can pass any Featurevisor initialization options directly to the provider. These options are used to create the Featurevisor instance owned by the provider:
|
|
860
|
+
|
|
861
|
+
```ruby
|
|
862
|
+
provider = Featurevisor::OpenFeatureProvider.new(
|
|
863
|
+
datafile: datafile_content,
|
|
864
|
+
targeting_key_field: "accountId",
|
|
865
|
+
key_separator: "/",
|
|
866
|
+
variation_key: "$variation",
|
|
867
|
+
)
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
You can also reuse an existing Featurevisor instance:
|
|
871
|
+
|
|
872
|
+
```ruby
|
|
873
|
+
featurevisor = Featurevisor.create_featurevisor(datafile: datafile_content)
|
|
874
|
+
provider = Featurevisor::OpenFeatureProvider.new(featurevisor: featurevisor)
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
The caller owns an instance passed this way. Provider shutdown does not close it. Call `featurevisor.close` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `featurevisor` takes precedence over the options hash.
|
|
878
|
+
|
|
879
|
+
See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, metadata, tracking, lifecycle, and providers for other languages.
|
|
880
|
+
|
|
814
881
|
<!-- FEATUREVISOR_DOCS_END -->
|
|
815
882
|
|
|
816
883
|
## Development
|
|
817
884
|
|
|
818
885
|
### Setting up
|
|
819
886
|
|
|
820
|
-
After checking out the
|
|
887
|
+
After checking out the repository, run `make install` to install dependencies.
|
|
821
888
|
|
|
822
889
|
### Running tests
|
|
823
890
|
|
|
824
891
|
```bash
|
|
825
892
|
$ bundle exec rspec
|
|
893
|
+
$ make test-base
|
|
894
|
+
$ make test-example-1
|
|
826
895
|
```
|
|
827
896
|
|
|
828
|
-
|
|
897
|
+
Build and verify both gems with:
|
|
898
|
+
|
|
899
|
+
```bash
|
|
900
|
+
$ make build
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
The build produces `featurevisor-VERSION.gem` and `featurevisor-openfeature-VERSION.gem`. The artifact verifier confirms that OpenFeature code and dependencies are absent from the base gem and present only in the provider gem.
|
|
829
904
|
|
|
830
905
|
### Releasing
|
|
831
906
|
|
|
832
|
-
- Update version in `lib/featurevisor/version.rb`
|
|
907
|
+
- Update the shared version in `lib/featurevisor/version.rb`
|
|
833
908
|
- Run `bundle install`
|
|
834
909
|
- Push commit to `main` branch
|
|
835
910
|
- Wait for CI to complete
|
|
836
|
-
- Tag the release with the version number
|
|
837
|
-
-
|
|
911
|
+
- Tag the release with the same version number, for example `v2.0.0`
|
|
912
|
+
- The workflow verifies that the tag matches the shared version
|
|
913
|
+
- The workflow publishes `featurevisor` first, followed by `featurevisor-openfeature`
|
|
914
|
+
|
|
915
|
+
The gems are separate RubyGems packages built from the same repository. Publishing the base gem first ensures the provider's exact Featurevisor dependency is available when the provider is published. If the second push fails, rerun or retry the provider publication without republishing the existing base version.
|
|
838
916
|
|
|
839
917
|
## License
|
|
840
918
|
|
data/bin/commands/test.rb
CHANGED
|
@@ -671,7 +671,7 @@ module FeaturevisorCLI
|
|
|
671
671
|
|
|
672
672
|
if assertion.key?(:expectedToMatch)
|
|
673
673
|
expected_to_match = assertion[:expectedToMatch]
|
|
674
|
-
actual = instance.instance_variable_get(:@
|
|
674
|
+
actual = instance.instance_variable_get(:@datafile).all_conditions_are_matched(conditions, context)
|
|
675
675
|
|
|
676
676
|
if actual != expected_to_match
|
|
677
677
|
has_error = true
|
|
@@ -30,16 +30,16 @@ module Featurevisor
|
|
|
30
30
|
# - feature_key [String] The feature key
|
|
31
31
|
# - bucket_by [String, Array<String>, Hash] Bucketing strategy
|
|
32
32
|
# - context [Hash] User context
|
|
33
|
-
# -
|
|
33
|
+
# - diagnostics [DiagnosticReporter] Diagnostic reporter
|
|
34
34
|
# @return [String] The bucket key
|
|
35
35
|
# @raise [StandardError] If bucket_by is invalid
|
|
36
36
|
def self.get_bucket_key(options)
|
|
37
37
|
feature_key = options[:feature_key]
|
|
38
38
|
bucket_by = options[:bucket_by]
|
|
39
39
|
context = options[:context]
|
|
40
|
-
|
|
40
|
+
diagnostics = options[:diagnostics]
|
|
41
41
|
|
|
42
|
-
type, attribute_keys = parse_bucket_by(bucket_by,
|
|
42
|
+
type, attribute_keys = parse_bucket_by(bucket_by, diagnostics, feature_key)
|
|
43
43
|
|
|
44
44
|
bucket_key = build_bucket_key(attribute_keys, context, type, feature_key)
|
|
45
45
|
|
|
@@ -50,10 +50,10 @@ module Featurevisor
|
|
|
50
50
|
|
|
51
51
|
# Parse bucket_by configuration to determine type and attribute keys
|
|
52
52
|
# @param bucket_by [String, Array<String>, Hash] Bucketing strategy
|
|
53
|
-
# @param
|
|
53
|
+
# @param diagnostics [DiagnosticReporter] Diagnostic reporter
|
|
54
54
|
# @param feature_key [String] Feature key for error logging
|
|
55
55
|
# @return [Array] Tuple of [type, attribute_keys]
|
|
56
|
-
def self.parse_bucket_by(bucket_by,
|
|
56
|
+
def self.parse_bucket_by(bucket_by, diagnostics, feature_key)
|
|
57
57
|
if bucket_by.is_a?(String)
|
|
58
58
|
["plain", [bucket_by]]
|
|
59
59
|
elsif bucket_by.is_a?(Array)
|
|
@@ -61,7 +61,7 @@ module Featurevisor
|
|
|
61
61
|
elsif bucket_by.is_a?(Hash) && bucket_by[:or].is_a?(Array)
|
|
62
62
|
["or", bucket_by[:or]]
|
|
63
63
|
else
|
|
64
|
-
|
|
64
|
+
diagnostics.error("invalid bucketBy", { feature_key: feature_key, bucket_by: bucket_by })
|
|
65
65
|
raise StandardError, "invalid bucketBy"
|
|
66
66
|
end
|
|
67
67
|
end
|
|
@@ -78,7 +78,7 @@ module Featurevisor
|
|
|
78
78
|
attribute_keys.each do |attribute_key|
|
|
79
79
|
attribute_value = Featurevisor::Conditions.get_value_from_context(context, attribute_key)
|
|
80
80
|
|
|
81
|
-
next if attribute_value.nil?
|
|
81
|
+
next if attribute_value.nil? && !context_path_exists?(context, attribute_key)
|
|
82
82
|
|
|
83
83
|
if type == "plain" || type == "and"
|
|
84
84
|
bucket_key << attribute_value
|
|
@@ -89,7 +89,61 @@ module Featurevisor
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
bucket_key << feature_key
|
|
92
|
-
bucket_key
|
|
92
|
+
bucket_key.map { |value| javascript_string(value) }
|
|
93
93
|
end
|
|
94
|
+
|
|
95
|
+
def self.context_path_exists?(context, path)
|
|
96
|
+
path.split(".").reduce(context) do |current, key|
|
|
97
|
+
return false unless current.is_a?(Hash)
|
|
98
|
+
return false unless current.key?(key.to_sym) || current.key?(key)
|
|
99
|
+
|
|
100
|
+
current.key?(key.to_sym) ? current[key.to_sym] : current[key]
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.javascript_string(value)
|
|
106
|
+
return "" if value.nil?
|
|
107
|
+
return value ? "true" : "false" if value == true || value == false
|
|
108
|
+
if value.is_a?(Float)
|
|
109
|
+
return "NaN" if value.nan?
|
|
110
|
+
return value.positive? ? "Infinity" : "-Infinity" if value.infinite?
|
|
111
|
+
return "0" if value.zero?
|
|
112
|
+
return value.to_i.to_s if value == value.to_i && value.abs < 1e21
|
|
113
|
+
if value.abs >= 1e-6 && value.abs < 1e21
|
|
114
|
+
return scientific_to_fixed(value.to_s)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
coefficient, exponent = value.to_s.downcase.split("e")
|
|
118
|
+
return "#{coefficient.delete_suffix('.0')}e#{Integer(exponent) >= 0 ? '+' : ''}#{Integer(exponent)}"
|
|
119
|
+
end
|
|
120
|
+
return value.map { |item| javascript_string(item) }.join(",") if value.is_a?(Array)
|
|
121
|
+
return "[object Object]" if value.is_a?(Hash)
|
|
122
|
+
|
|
123
|
+
value.to_s
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.scientific_to_fixed(value)
|
|
127
|
+
return value unless value.downcase.include?("e")
|
|
128
|
+
|
|
129
|
+
coefficient, exponent_text = value.downcase.split("e")
|
|
130
|
+
exponent = Integer(exponent_text)
|
|
131
|
+
negative = coefficient.start_with?("-")
|
|
132
|
+
digits = coefficient.delete_prefix("-").delete(".")
|
|
133
|
+
decimal_position = coefficient.delete_prefix("-").index(".") || coefficient.delete_prefix("-").length
|
|
134
|
+
decimal_position += exponent
|
|
135
|
+
|
|
136
|
+
result = if decimal_position <= 0
|
|
137
|
+
"0.#{"0" * -decimal_position}#{digits}"
|
|
138
|
+
elsif decimal_position >= digits.length
|
|
139
|
+
"#{digits}#{"0" * (decimal_position - digits.length)}"
|
|
140
|
+
else
|
|
141
|
+
"#{digits[0...decimal_position]}.#{digits[decimal_position..]}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
result = result.sub(/(\.\d*?)0+\z/, '\1').delete_suffix(".")
|
|
145
|
+
negative ? "-#{result}" : result
|
|
146
|
+
end
|
|
147
|
+
private_class_method :scientific_to_fixed
|
|
94
148
|
end
|
|
95
149
|
end
|
|
@@ -13,6 +13,7 @@ module Featurevisor
|
|
|
13
13
|
@context = options[:context] || {}
|
|
14
14
|
@sticky = options[:sticky] || {}
|
|
15
15
|
@emitter = Featurevisor::Emitter.new
|
|
16
|
+
@parent_unsubscribers = []
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# Subscribe to an event
|
|
@@ -25,12 +26,25 @@ module Featurevisor
|
|
|
25
26
|
if event_name == "context_set" || event_name == "sticky_set"
|
|
26
27
|
@emitter.on(event_name, callback)
|
|
27
28
|
else
|
|
28
|
-
@parent.on(event_name, callback)
|
|
29
|
+
parent_unsubscribe = @parent.on(event_name, callback)
|
|
30
|
+
active = true
|
|
31
|
+
unsubscribe = nil
|
|
32
|
+
unsubscribe = proc do
|
|
33
|
+
next unless active
|
|
34
|
+
|
|
35
|
+
active = false
|
|
36
|
+
parent_unsubscribe.call
|
|
37
|
+
@parent_unsubscribers.delete(unsubscribe)
|
|
38
|
+
end
|
|
39
|
+
@parent_unsubscribers << unsubscribe
|
|
40
|
+
unsubscribe
|
|
29
41
|
end
|
|
30
42
|
end
|
|
31
43
|
|
|
32
44
|
# Close the child instance
|
|
33
45
|
def close
|
|
46
|
+
@parent_unsubscribers.dup.each(&:call)
|
|
47
|
+
@parent_unsubscribers.clear
|
|
34
48
|
@emitter.clear_all
|
|
35
49
|
end
|
|
36
50
|
|
|
@@ -98,6 +112,15 @@ module Featurevisor
|
|
|
98
112
|
)
|
|
99
113
|
end
|
|
100
114
|
|
|
115
|
+
# Evaluate a feature flag and return its full evaluation details.
|
|
116
|
+
def evaluate_flag(feature_key, context = {}, options = {})
|
|
117
|
+
@parent.evaluate_flag(
|
|
118
|
+
feature_key,
|
|
119
|
+
{ **@context, **context },
|
|
120
|
+
{ **options, __featurevisor_child_sticky: @sticky }
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
101
124
|
# Get variation value
|
|
102
125
|
# @param feature_key [String] Feature key
|
|
103
126
|
# @param context [Hash] Context
|
|
@@ -117,6 +140,15 @@ module Featurevisor
|
|
|
117
140
|
)
|
|
118
141
|
end
|
|
119
142
|
|
|
143
|
+
# Evaluate a variation and return its full evaluation details.
|
|
144
|
+
def evaluate_variation(feature_key, context = {}, options = {})
|
|
145
|
+
@parent.evaluate_variation(
|
|
146
|
+
feature_key,
|
|
147
|
+
{ **@context, **context },
|
|
148
|
+
{ **options, __featurevisor_child_sticky: @sticky }
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
120
152
|
# Get variable value
|
|
121
153
|
# @param feature_key [String] Feature key
|
|
122
154
|
# @param variable_key [String] Variable key
|
|
@@ -138,6 +170,16 @@ module Featurevisor
|
|
|
138
170
|
)
|
|
139
171
|
end
|
|
140
172
|
|
|
173
|
+
# Evaluate a variable and return its full evaluation details.
|
|
174
|
+
def evaluate_variable(feature_key, variable_key, context = {}, options = {})
|
|
175
|
+
@parent.evaluate_variable(
|
|
176
|
+
feature_key,
|
|
177
|
+
variable_key,
|
|
178
|
+
{ **@context, **context },
|
|
179
|
+
{ **options, __featurevisor_child_sticky: @sticky }
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
141
183
|
# Get variable as boolean
|
|
142
184
|
# @param feature_key [String] Feature key
|
|
143
185
|
# @param variable_key [String] Variable key
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "date"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module Featurevisor
|
|
6
7
|
# Conditions module for evaluating feature flags and segments
|
|
7
8
|
module Conditions
|
|
9
|
+
MISSING = Object.new.freeze
|
|
10
|
+
private_constant :MISSING
|
|
11
|
+
|
|
8
12
|
# Get value from context object using dot notation path
|
|
9
13
|
# @param obj [Hash] Context object
|
|
10
14
|
# @param path [String] Dot-separated path to the value
|
|
@@ -12,12 +16,36 @@ module Featurevisor
|
|
|
12
16
|
def self.get_value_from_context(obj, path)
|
|
13
17
|
return nil if obj.nil? || path.nil?
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
value = get_value_with_presence(obj, path)
|
|
20
|
+
value.equal?(MISSING) ? nil : value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.get_value_with_presence(obj, path)
|
|
24
|
+
return MISSING unless obj.is_a?(Hash) && path.is_a?(String)
|
|
25
|
+
|
|
26
|
+
path.split(".").reduce(obj) do |current, key|
|
|
27
|
+
break MISSING unless current.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
if current.key?(key.to_sym)
|
|
30
|
+
current[key.to_sym]
|
|
31
|
+
elsif current.key?(key)
|
|
32
|
+
current[key]
|
|
33
|
+
else
|
|
34
|
+
break MISSING
|
|
35
|
+
end
|
|
17
36
|
end
|
|
37
|
+
end
|
|
38
|
+
private_class_method :get_value_with_presence
|
|
39
|
+
|
|
40
|
+
def self.strict_equal?(left, right)
|
|
41
|
+
return true if left.nil? && right.nil?
|
|
42
|
+
return left.to_f == right.to_f if left.is_a?(Numeric) && right.is_a?(Numeric)
|
|
43
|
+
return left == right if left.is_a?(String) && right.is_a?(String)
|
|
44
|
+
return left == right if (left == true || left == false) && (right == true || right == false)
|
|
18
45
|
|
|
19
|
-
|
|
46
|
+
false
|
|
20
47
|
end
|
|
48
|
+
private_class_method :strict_equal?
|
|
21
49
|
|
|
22
50
|
# Check if a condition is matched against context
|
|
23
51
|
# @param condition [Hash] Condition to evaluate
|
|
@@ -30,18 +58,19 @@ module Featurevisor
|
|
|
30
58
|
value = condition["value"] || condition[:value]
|
|
31
59
|
regex_flags = condition["regexFlags"] || condition[:regexFlags]
|
|
32
60
|
|
|
33
|
-
|
|
61
|
+
raw_context_value = get_value_with_presence(context, attribute)
|
|
62
|
+
context_value_from_path = raw_context_value.equal?(MISSING) ? nil : raw_context_value
|
|
63
|
+
attribute_exists = !raw_context_value.equal?(MISSING)
|
|
34
64
|
|
|
35
65
|
case operator
|
|
36
66
|
when "equals"
|
|
37
|
-
context_value_from_path
|
|
67
|
+
attribute_exists && strict_equal?(context_value_from_path, value)
|
|
38
68
|
when "notEquals"
|
|
39
|
-
context_value_from_path
|
|
69
|
+
!attribute_exists || !strict_equal?(context_value_from_path, value)
|
|
40
70
|
when "before", "after"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
date_in_condition = value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
71
|
+
date_in_context = portable_date(context_value_from_path)
|
|
72
|
+
date_in_condition = portable_date(value)
|
|
73
|
+
return false unless date_in_context && date_in_condition
|
|
45
74
|
|
|
46
75
|
if operator == "before"
|
|
47
76
|
date_in_context < date_in_condition
|
|
@@ -50,21 +79,13 @@ module Featurevisor
|
|
|
50
79
|
end
|
|
51
80
|
when "in", "notIn"
|
|
52
81
|
# in / notIn (where condition value is an array)
|
|
53
|
-
if value.is_a?(Array) &&
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# If key doesn't exist, notIn should fail (return false), in should also fail
|
|
58
|
-
if !key_exists
|
|
59
|
-
return false
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
value_in_context = context_value_from_path.to_s
|
|
63
|
-
|
|
82
|
+
if attribute_exists && value.is_a?(Array) &&
|
|
83
|
+
(context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Numeric) || context_value_from_path.nil?)
|
|
84
|
+
matched = value.any? { |candidate| strict_equal?(candidate, context_value_from_path) }
|
|
64
85
|
if operator == "in"
|
|
65
|
-
|
|
86
|
+
matched
|
|
66
87
|
else # notIn
|
|
67
|
-
!
|
|
88
|
+
!matched
|
|
68
89
|
end
|
|
69
90
|
else
|
|
70
91
|
false
|
|
@@ -124,18 +145,18 @@ module Featurevisor
|
|
|
124
145
|
false
|
|
125
146
|
end
|
|
126
147
|
when "exists"
|
|
127
|
-
|
|
148
|
+
attribute_exists
|
|
128
149
|
when "notExists"
|
|
129
|
-
|
|
150
|
+
!attribute_exists
|
|
130
151
|
when "includes", "notIncludes"
|
|
131
152
|
# includes / notIncludes (where context value is an array)
|
|
132
|
-
if context_value_from_path.is_a?(Array) &&
|
|
133
|
-
|
|
134
|
-
|
|
153
|
+
if context_value_from_path.is_a?(Array) &&
|
|
154
|
+
(value.is_a?(String) || value.is_a?(Numeric) || value == true || value == false || value.nil?)
|
|
155
|
+
matched = context_value_from_path.any? { |candidate| strict_equal?(candidate, value) }
|
|
135
156
|
if operator == "includes"
|
|
136
|
-
|
|
157
|
+
matched
|
|
137
158
|
else # notIncludes
|
|
138
|
-
!
|
|
159
|
+
!matched
|
|
139
160
|
end
|
|
140
161
|
else
|
|
141
162
|
false
|
|
@@ -143,10 +164,18 @@ module Featurevisor
|
|
|
143
164
|
else
|
|
144
165
|
false
|
|
145
166
|
end
|
|
146
|
-
rescue => e
|
|
147
|
-
# Log error but don't stop execution
|
|
148
|
-
warn "Error in condition evaluation: #{e.message}"
|
|
149
|
-
false
|
|
150
167
|
end
|
|
168
|
+
|
|
169
|
+
def self.portable_date(value)
|
|
170
|
+
return value if value.is_a?(Time) || value.is_a?(DateTime)
|
|
171
|
+
return value.to_time if value.is_a?(Date)
|
|
172
|
+
return nil unless value.is_a?(String)
|
|
173
|
+
return nil unless value.match?(/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})\z/)
|
|
174
|
+
|
|
175
|
+
Time.iso8601(value)
|
|
176
|
+
rescue ArgumentError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
private_class_method :portable_date
|
|
151
180
|
end
|
|
152
181
|
end
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Featurevisor
|
|
4
|
-
#
|
|
4
|
+
# Diagnostic severity levels
|
|
5
5
|
LOG_LEVELS = %w[fatal error warn info debug].freeze
|
|
6
6
|
DEFAULT_LOG_LEVEL = "info".freeze
|
|
7
|
-
|
|
7
|
+
DIAGNOSTIC_PREFIX = "[Featurevisor]".freeze
|
|
8
8
|
|
|
9
|
-
#
|
|
10
|
-
class
|
|
9
|
+
# Private evaluator adapter for structured diagnostics.
|
|
10
|
+
class DiagnosticReporter
|
|
11
11
|
attr_reader :level, :handler
|
|
12
12
|
|
|
13
|
-
# Initialize a
|
|
14
|
-
# @param options [Hash]
|
|
13
|
+
# Initialize a diagnostic reporter.
|
|
14
|
+
# @param options [Hash] Reporter options
|
|
15
15
|
# @option options [String] :level Log level (default: "info")
|
|
16
|
-
# @option options [Proc] :handler
|
|
16
|
+
# @option options [Proc] :handler Internal structured diagnostic sink
|
|
17
17
|
def initialize(options = {})
|
|
18
18
|
@level = options[:level] || DEFAULT_LOG_LEVEL
|
|
19
|
+
@filter = !options.key?(:handler)
|
|
19
20
|
@handler = options[:handler] || method(:default_log_handler)
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -25,12 +26,13 @@ module Featurevisor
|
|
|
25
26
|
@level = level
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# @param
|
|
29
|
+
# Forward an evaluator diagnostic. Filtering is performed centrally by
|
|
30
|
+
# Instance so module subscriptions and error events remain independent.
|
|
31
|
+
# @param level [String] Diagnostic level
|
|
32
|
+
# @param message [String] Diagnostic message
|
|
31
33
|
# @param details [Hash, nil] Additional details
|
|
32
34
|
def log(level, message, details = nil)
|
|
33
|
-
return
|
|
35
|
+
return if @filter && !should_handle?(level)
|
|
34
36
|
|
|
35
37
|
@handler.call(level, message, details)
|
|
36
38
|
end
|
|
@@ -72,16 +74,8 @@ module Featurevisor
|
|
|
72
74
|
|
|
73
75
|
private
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# @return [Boolean] True if should handle
|
|
78
|
-
def should_handle?(log_level)
|
|
79
|
-
current_index = LOG_LEVELS.index(@level)
|
|
80
|
-
target_index = LOG_LEVELS.index(log_level)
|
|
81
|
-
|
|
82
|
-
return false if current_index.nil? || target_index.nil?
|
|
83
|
-
|
|
84
|
-
current_index >= target_index
|
|
77
|
+
def should_handle?(level)
|
|
78
|
+
LOG_LEVELS.index(level).to_i <= LOG_LEVELS.index(@level).to_i
|
|
85
79
|
end
|
|
86
80
|
|
|
87
81
|
# Default log handler that outputs to console
|
|
@@ -99,45 +93,18 @@ module Featurevisor
|
|
|
99
93
|
case method_name
|
|
100
94
|
when "puts"
|
|
101
95
|
if details && !details.empty?
|
|
102
|
-
Kernel.puts("#{
|
|
96
|
+
Kernel.puts("#{DIAGNOSTIC_PREFIX} #{message} #{details.inspect}")
|
|
103
97
|
else
|
|
104
|
-
Kernel.puts("#{
|
|
98
|
+
Kernel.puts("#{DIAGNOSTIC_PREFIX} #{message}")
|
|
105
99
|
end
|
|
106
100
|
when "warn"
|
|
107
101
|
if details && !details.empty?
|
|
108
|
-
Kernel.warn("#{
|
|
102
|
+
Kernel.warn("#{DIAGNOSTIC_PREFIX} #{message} #{details.inspect}")
|
|
109
103
|
else
|
|
110
|
-
Kernel.warn("#{
|
|
104
|
+
Kernel.warn("#{DIAGNOSTIC_PREFIX} #{message}")
|
|
111
105
|
end
|
|
112
106
|
end
|
|
113
107
|
end
|
|
114
108
|
end
|
|
115
109
|
|
|
116
|
-
# Default log handler function
|
|
117
|
-
# @param level [String] Log level
|
|
118
|
-
# @param message [String] Log message
|
|
119
|
-
# @param details [Hash, nil] Additional details
|
|
120
|
-
def self.default_log_handler(level, message, details = nil)
|
|
121
|
-
method_name = case level
|
|
122
|
-
when "info" then "puts"
|
|
123
|
-
when "warn" then "warn"
|
|
124
|
-
when "error", "fatal" then "warn"
|
|
125
|
-
else "puts"
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
case method_name
|
|
129
|
-
when "puts"
|
|
130
|
-
if details && !details.empty?
|
|
131
|
-
Kernel.puts("#{LOGGER_PREFIX} #{message} #{details.inspect}")
|
|
132
|
-
else
|
|
133
|
-
Kernel.puts("#{LOGGER_PREFIX} #{message}")
|
|
134
|
-
end
|
|
135
|
-
when "warn"
|
|
136
|
-
if details && !details.empty?
|
|
137
|
-
Kernel.warn("#{LOGGER_PREFIX} #{message} #{details.inspect}")
|
|
138
|
-
else
|
|
139
|
-
Kernel.warn("#{LOGGER_PREFIX} #{message}")
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
110
|
end
|