factbase 0.17.0 → 0.18.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e2593945542e79d93c93e662467bb704ad98d9a481887b5447341c700e4abf8
4
- data.tar.gz: 5e13613617c829b0669235a275a1796c6556ca8cdced3ef9ee3502cbb2c300db
3
+ metadata.gz: 90b1ab3d82ed371b4de11b7eceb98137b2a7687bc2625e61d4d54a6538849b34
4
+ data.tar.gz: f9fd82d8254d7cfa646a742db6989790791ce157ce53d9e5a310f8f8f8c0db11
5
5
  SHA512:
6
- metadata.gz: 22d7965bd3b3f60070ca8293e3c054ea5b2553913ccb091328f3d51f2aab3a42c3be56b5197536014c550eecceb708186a826cb1fe67b6e31deb636819ceb866
7
- data.tar.gz: 5e5fef55d3e316d19d5d96c2de10b23d1788541c09a2331c8762cd73c1c4b15b468c7cb4c0ff4b4f33f3b296f0a875673568f7a1eca92744fbf54c2e0c042233
6
+ metadata.gz: ae0d064e075f304c9fae6f4903c961293d07e186ee6bdf4e180d5f407f5b166f2b3cc345cec612d8184675394a2ee504197d4c13d9ce0f480cffd850d7d10169
7
+ data.tar.gz: b53da29f44f9ad5520bed3602c9c7dadf882115856ddb344dfba95a32017cd2e89c4582a7a160baa2ed611ff37acd0ba39e8fb9a8aa8ed7bec62f0acf9f0ee70
data/Gemfile CHANGED
@@ -12,12 +12,13 @@ gem 'minitest-reporters', '~>1.7', require: false
12
12
  gem 'os', '~>1.1', require: false
13
13
  gem 'qbash', '~>0.4', require: false
14
14
  gem 'rake', '~>13.2', require: false
15
- gem 'rdoc', '6.15.1', require: false # GPL
15
+ gem 'rdoc', '6.17.0', require: false # GPL
16
16
  gem 'rubocop', '~>1.74', require: false
17
17
  gem 'rubocop-minitest', '~>0.38', require: false
18
18
  gem 'rubocop-performance', '~>1.25', require: false
19
19
  gem 'rubocop-rake', '~>0.7', require: false
20
20
  gem 'simplecov', '~>0.22', require: false
21
21
  gem 'simplecov-cobertura', '~>3.0', require: false
22
+ gem 'stackprof', '~>0.2', require: false, platforms: [:ruby]
22
23
  gem 'threads', '~>0.4', require: false
23
24
  gem 'yard', '~>0.9', require: false
data/Gemfile.lock CHANGED
@@ -30,7 +30,7 @@ GEM
30
30
  tago (~> 0.1)
31
31
  ellipsized (0.3.0)
32
32
  erb (6.0.0)
33
- json (2.16.0)
33
+ json (2.17.1)
34
34
  language_server-protocol (3.17.0.5)
35
35
  lint_roller (1.1.0)
36
36
  logger (1.7.0)
@@ -68,7 +68,7 @@ GEM
68
68
  racc (1.8.1)
69
69
  rainbow (3.1.1)
70
70
  rake (13.3.1)
71
- rdoc (6.15.1)
71
+ rdoc (6.17.0)
72
72
  erb
73
73
  psych (>= 4.0.0)
74
74
  tsort
@@ -109,7 +109,8 @@ GEM
109
109
  simplecov (~> 0.19)
110
110
  simplecov-html (0.13.2)
111
111
  simplecov_json_formatter (0.1.4)
112
- stringio (3.1.8)
112
+ stackprof (0.2.27)
113
+ stringio (3.1.9)
113
114
  tago (0.4.0)
114
115
  threads (0.5.0)
115
116
  backtrace (~> 0)
@@ -119,7 +120,7 @@ GEM
119
120
  unicode-emoji (~> 4.1)
120
121
  unicode-emoji (4.1.0)
121
122
  yaml (0.4.0)
122
- yard (0.9.37)
123
+ yard (0.9.38)
123
124
 
124
125
  PLATFORMS
125
126
  arm64-darwin-22
@@ -139,13 +140,14 @@ DEPENDENCIES
139
140
  os (~> 1.1)
140
141
  qbash (~> 0.4)
141
142
  rake (~> 13.2)
142
- rdoc (= 6.15.1)
143
+ rdoc (= 6.17.0)
143
144
  rubocop (~> 1.74)
144
145
  rubocop-minitest (~> 0.38)
145
146
  rubocop-performance (~> 1.25)
146
147
  rubocop-rake (~> 0.7)
147
148
  simplecov (~> 0.22)
148
149
  simplecov-cobertura (~> 3.0)
150
+ stackprof (~> 0.2)
149
151
  threads (~> 0.4)
150
152
  yard (~> 0.9)
151
153
 
data/Rakefile CHANGED
@@ -78,3 +78,19 @@ task :benchmark, [:name] do |_t, args|
78
78
  end
79
79
  end
80
80
  end
81
+
82
+ # Run profiling on a benchmark and generate a flamegraph.
83
+ # To run this task, you need to have stackprof installed.
84
+ # https://github.com/tmm1/stackprof
85
+ # To run profiling for a specific benchmark you can run:
86
+ # bundle exec rake flamegraph\[bench_slow_query\]
87
+ desc 'Profile a benchmark (e.g., flamegraph[bench_slow_query])'
88
+ task :flamegraph, [:name] do |_t, args|
89
+ require 'stackprof'
90
+ bname = args[:name] || 'all'
91
+ puts "Starting profiling for '#{bname}'..."
92
+ StackProf.run(mode: :cpu, out: 'stackprof-cpu-myapp.dump', raw: true) do
93
+ Rake::Task['benchmark'].invoke(bname)
94
+ end
95
+ `stackprof --d3-flamegraph stackprof-cpu-myapp.dump > flamegraph.html`
96
+ end
@@ -57,9 +57,12 @@ class Factbase::IndexedFactbase
57
57
  # Run an ACID transaction.
58
58
  # @return [Factbase::Churn] How many facts have been changed (zero if rolled back)
59
59
  def txn
60
- @origin.txn do |fbt|
61
- yield Factbase::IndexedFactbase.new(fbt, @idx)
62
- end
60
+ result =
61
+ @origin.txn do |fbt|
62
+ yield Factbase::IndexedFactbase.new(fbt, @idx)
63
+ end
64
+ @idx.clear
65
+ result
63
66
  end
64
67
 
65
68
  # Export it into a chain of bytes, including both data and index.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative '../factbase'
7
+ require_relative 'taped'
8
+ require_relative 'lazy_taped_hash'
9
+
10
+ # A lazy decorator of an Array with HashMaps that defers copying until modification.
11
+ class Factbase::LazyTaped
12
+ def initialize(origin)
13
+ @origin = origin
14
+ @copied = false
15
+ @maps = nil
16
+ @pairs = nil
17
+ @inserted = []
18
+ @deleted = []
19
+ @added = []
20
+ end
21
+
22
+ # Returns a hash mapping copied maps to their originals.
23
+ # This is used during transaction commit to identify which original facts
24
+ # were modified, allowing the factbase to update the correct entries.
25
+ def pairs
26
+ return {} unless @pairs
27
+ result = {}.compare_by_identity
28
+ @pairs.each { |copied, original| result[copied] = original }
29
+ result
30
+ end
31
+
32
+ # Returns the unique object IDs of maps that were inserted (newly created).
33
+ # This is used during transaction commit to identify new facts that need
34
+ # to be added to the factbase.
35
+ def inserted
36
+ @inserted.uniq
37
+ end
38
+
39
+ # Returns the unique object IDs of maps that were deleted.
40
+ # This is used during transaction commit to identify facts that need
41
+ # to be removed from the factbase.
42
+ def deleted
43
+ @deleted.uniq
44
+ end
45
+
46
+ # Returns the unique object IDs of maps that were modified (properties added).
47
+ # This is used during transaction commit to track the churn (number of changes).
48
+ def added
49
+ @added.uniq
50
+ end
51
+
52
+ def find_by_object_id(oid)
53
+ (@maps || @origin).find { |m| m.object_id == oid }
54
+ end
55
+
56
+ def size
57
+ (@maps || @origin).size
58
+ end
59
+
60
+ def empty?
61
+ (@maps || @origin).empty?
62
+ end
63
+
64
+ def <<(map)
65
+ ensure_copied
66
+ @maps << map
67
+ @inserted.append(map.object_id)
68
+ end
69
+
70
+ def each
71
+ return to_enum(__method__) unless block_given?
72
+ if @copied
73
+ @maps.each do |m|
74
+ yield Factbase::Taped::TapedHash.new(m, @added)
75
+ end
76
+ else
77
+ @origin.each do |m|
78
+ yield LazyTapedHash.new(m, self, @added)
79
+ end
80
+ end
81
+ end
82
+
83
+ def delete_if
84
+ ensure_copied
85
+ @maps.delete_if do |m|
86
+ r = yield m
87
+ @deleted.append(@pairs[m].object_id) if r
88
+ r
89
+ end
90
+ end
91
+
92
+ def to_a
93
+ (@maps || @origin).to_a
94
+ end
95
+
96
+ def &(other)
97
+ if other == [] || (@maps || @origin).empty?
98
+ return Factbase::Taped.new([], inserted: @inserted, deleted: @deleted, added: @added)
99
+ end
100
+ join(other, &:&)
101
+ end
102
+
103
+ def |(other)
104
+ return Factbase::Taped.new(to_a, inserted: @inserted, deleted: @deleted, added: @added) if other == []
105
+ if (@maps || @origin).empty?
106
+ return Factbase::Taped.new(other, inserted: @inserted, deleted: @deleted, added: @added)
107
+ end
108
+ join(other, &:|)
109
+ end
110
+
111
+ def ensure_copied
112
+ return if @copied
113
+ @pairs = {}.compare_by_identity
114
+ @maps =
115
+ @origin.map do |m|
116
+ n = m.transform_values(&:dup)
117
+ @pairs[n] = m
118
+ n
119
+ end
120
+ @copied = true
121
+ end
122
+
123
+ def get_copied_map(original_map)
124
+ ensure_copied
125
+ @maps.find { |m| @pairs[m].equal?(original_map) }
126
+ end
127
+
128
+ private
129
+
130
+ def join(other)
131
+ n = yield (@maps || @origin).to_a, other.to_a
132
+ raise 'Cannot join with another Taped' if other.respond_to?(:inserted)
133
+ raise 'Can only join with array' unless other.is_a?(Array)
134
+ Factbase::Taped.new(
135
+ n,
136
+ inserted: @inserted,
137
+ deleted: @deleted,
138
+ added: @added
139
+ )
140
+ end
141
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative '../factbase'
7
+
8
+ class Factbase::LazyTaped
9
+ # Decorator of Array that triggers copy-on-write.
10
+ # @todo #424:30min Add dedicated unit tests for LazyTapedArray class.
11
+ # Currently this class is tested indirectly through LazyTaped tests.
12
+ # We should add explicit tests for all public methods including each, [],
13
+ # to_a, any?, <<, and uniq! to ensure proper copy-on-write behavior.
14
+ class LazyTapedArray
15
+ # Creates a new lazy array wrapper.
16
+ # @param origin [Array] The original array to wrap
17
+ # @param key [String] The key in the parent hash where this array is stored
18
+ # @param taped_hash [LazyTapedHash] The parent hash wrapper that owns this array
19
+ # @param added [Array] Accumulator for tracking object IDs of modified facts
20
+ def initialize(origin, key, taped_hash, added)
21
+ @origin = origin
22
+ @key = key
23
+ @taped_hash = taped_hash
24
+ @added = added
25
+ end
26
+
27
+ def each(&)
28
+ return to_enum(__method__) unless block_given?
29
+ current_array.each(&)
30
+ end
31
+
32
+ def [](idx)
33
+ current_array[idx]
34
+ end
35
+
36
+ def to_a
37
+ current_array.to_a
38
+ end
39
+
40
+ def any?(pattern = nil, &)
41
+ pattern ? current_array.any?(pattern) : current_array.any?(&)
42
+ end
43
+
44
+ def <<(item)
45
+ @taped_hash.ensure_copied_map
46
+ @added.append(@taped_hash.tracking_id)
47
+ @taped_hash.get_copied_array(@key) << item
48
+ end
49
+
50
+ def uniq!
51
+ @taped_hash.ensure_copied_map
52
+ @added.append(@taped_hash.tracking_id)
53
+ @taped_hash.get_copied_array(@key).uniq!
54
+ end
55
+
56
+ private
57
+
58
+ def current_array
59
+ @taped_hash.copied? ? @taped_hash.get_copied_array(@key) : @origin
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative '../factbase'
7
+ require_relative 'lazy_taped_array'
8
+
9
+ class Factbase::LazyTaped
10
+ # Decorator of Hash that triggers copy-on-write.
11
+ # @todo #424:30min Add dedicated unit tests for LazyTapedHash class.
12
+ # Currently this class is tested indirectly through LazyTaped tests.
13
+ # We should add explicit tests for all public methods including keys, map,
14
+ # bracket access, bracket assignment, and the copy-on-write behavior.
15
+ class LazyTapedHash
16
+ # Creates a new LazyTapedHash decorator.
17
+ # @param origin [Hash] The original hash being wrapped (not yet copied)
18
+ # @param lazy_taped [Factbase::LazyTaped] The parent LazyTaped instance that manages copy-on-write
19
+ # @param added [Array] Array to track object IDs of maps that have been modified
20
+ def initialize(origin, lazy_taped, added)
21
+ @origin = origin
22
+ @lazy_taped = lazy_taped
23
+ @added = added
24
+ @copied_map = nil
25
+ end
26
+
27
+ def keys
28
+ current_map.keys
29
+ end
30
+
31
+ def map(&)
32
+ current_map.map(&)
33
+ end
34
+
35
+ def [](key)
36
+ v = current_map[key]
37
+ v = LazyTapedArray.new(v, key, self, @added) if v.is_a?(Array)
38
+ v
39
+ end
40
+
41
+ def []=(key, value)
42
+ ensure_copied_map
43
+ @copied_map[key] = value
44
+ @added.append(@copied_map.object_id)
45
+ end
46
+
47
+ def ensure_copied_map
48
+ return if @copied_map
49
+ @copied_map = @lazy_taped.get_copied_map(@origin)
50
+ end
51
+
52
+ def get_copied_array(key)
53
+ ensure_copied_map
54
+ @copied_map[key]
55
+ end
56
+
57
+ def tracking_id
58
+ @copied_map ? @copied_map.object_id : @origin.object_id
59
+ end
60
+
61
+ def copied?
62
+ !@copied_map.nil?
63
+ end
64
+
65
+ private
66
+
67
+ def current_map
68
+ @copied_map || @origin
69
+ end
70
+
71
+ def method_missing(method, *, &)
72
+ current_map.send(method, *, &)
73
+ end
74
+
75
+ def respond_to_missing?(method, include_private = false)
76
+ current_map.respond_to?(method, include_private)
77
+ end
78
+ end
79
+ end
data/lib/factbase/term.rb CHANGED
@@ -52,6 +52,12 @@ require_relative 'terms/when'
52
52
  require_relative 'terms/either'
53
53
  require_relative 'terms/count'
54
54
  require_relative 'terms/first'
55
+ require_relative 'terms/nth'
56
+ require_relative 'terms/sum'
57
+ require_relative 'terms/agg'
58
+ require_relative 'terms/empty'
59
+ require_relative 'terms/min'
60
+ require_relative 'terms/max'
55
61
 
56
62
  # Term.
57
63
  #
@@ -80,7 +86,7 @@ require_relative 'terms/first'
80
86
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
81
87
  # Copyright:: Copyright (c) 2024-2025 Yegor Bugayenko
82
88
  # License:: MIT
83
- class Factbase::Term
89
+ class Factbase::Term < Factbase::TermBase
84
90
  # The operator of this term
85
91
  # @return [Symbol] The operator
86
92
  attr_reader :op
@@ -89,19 +95,11 @@ class Factbase::Term
89
95
  # @return [Array] The operands
90
96
  attr_reader :operands
91
97
 
92
- require_relative 'terms/logical'
93
- include Factbase::Logical
94
-
95
- require_relative 'terms/aggregates'
96
- include Factbase::Aggregates
97
-
98
- require_relative 'terms/shared'
99
- include Factbase::TermShared
100
-
101
98
  # Ctor.
102
99
  # @param [Symbol] operator Operator
103
100
  # @param [Array] operands Operands
104
101
  def initialize(operator, operands)
102
+ super()
105
103
  @op = operator
106
104
  @operands = operands
107
105
  @terms = {
@@ -149,7 +147,13 @@ class Factbase::Term
149
147
  when: Factbase::When.new(operands),
150
148
  either: Factbase::Either.new(operands),
151
149
  count: Factbase::Count.new(operands),
152
- first: Factbase::First.new(operands)
150
+ first: Factbase::First.new(operands),
151
+ nth: Factbase::Nth.new(operands),
152
+ sum: Factbase::Sum.new(operands),
153
+ agg: Factbase::Agg.new(operands),
154
+ empty: Factbase::Empty.new(operands),
155
+ min: Factbase::Min.new(operands),
156
+ max: Factbase::Max.new(operands)
153
157
  }
154
158
  end
155
159
 
@@ -211,11 +215,15 @@ class Factbase::Term
211
215
  # Simplify it if possible.
212
216
  # @return [Factbase::Term] New term or itself
213
217
  def simplify
214
- m = "#{@op}_simplify"
215
- if respond_to?(m, true)
216
- send(m)
218
+ if @terms.key?(@op) && @terms[@op].respond_to?(:simplify)
219
+ @terms[@op].simplify
217
220
  else
218
- self
221
+ m = "#{@op}_simplify"
222
+ if respond_to?(m, true)
223
+ send(m)
224
+ else
225
+ self
226
+ end
219
227
  end
220
228
  end
221
229
 
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative 'base'
7
+ # The term 'agg' that aggregates.
8
+ class Factbase::Agg < Factbase::TermBase
9
+ # Constructor.
10
+ # @param [Array] operands Operands
11
+ def initialize(operands = [])
12
+ super()
13
+ @operands = operands
14
+ @op = :agg
15
+ end
16
+
17
+ # Evaluate term on a fact.
18
+ # @param [Factbase::Fact] fact The fact
19
+ # @param [Array<Factbase::Fact>] maps All maps available
20
+ # @param [Factbase] fb Factbase to use for sub-queries
21
+ # @return [Object] The result of evaluation
22
+ def evaluate(fact, maps, fb)
23
+ assert_args(2)
24
+ selector = @operands[0]
25
+ unless selector.is_a?(Factbase::Term) || selector.is_a?(Factbase::TermBase)
26
+ raise "A term is expected, but '#{selector}' provided"
27
+ end
28
+ term = @operands[1]
29
+ unless term.is_a?(Factbase::Term) || term.is_a?(Factbase::TermBase)
30
+ raise "A term is expected, but '#{term}' provided"
31
+ end
32
+ subset = fb.query(selector, maps).each(fb, fact).to_a
33
+ term.evaluate(nil, subset, fb)
34
+ end
35
+ end
@@ -5,6 +5,7 @@
5
5
 
6
6
  require_relative 'base'
7
7
  require_relative 'boolean'
8
+ require_relative 'simplified'
8
9
  # The 'and' term that represents a logical AND operation between multiple operands.
9
10
  class Factbase::And < Factbase::TermBase
10
11
  # Constructor.
@@ -25,4 +26,10 @@ class Factbase::And < Factbase::TermBase
25
26
  end
26
27
  true
27
28
  end
29
+
30
+ def simplify
31
+ unique = Factbase::Simplified.new(@operands).unique
32
+ return unique[0] if unique.size == 1
33
+ Factbase::Term.new(@op, unique)
34
+ end
28
35
  end
@@ -10,8 +10,61 @@
10
10
 
11
11
  # Base class for all terms.
12
12
  class Factbase::TermBase
13
- require_relative 'shared'
14
- include Factbase::TermShared
13
+ # Turns it into a string.
14
+ # @return [String] The string of it
15
+ def to_s
16
+ @to_s ||=
17
+ begin
18
+ items = []
19
+ items << @op
20
+ items +=
21
+ @operands.map do |o|
22
+ if o.is_a?(String)
23
+ "'#{o.gsub("'", "\\\\'").gsub('"', '\\\\"')}'"
24
+ elsif o.is_a?(Time)
25
+ o.utc.iso8601
26
+ else
27
+ o.to_s
28
+ end
29
+ end
30
+ "(#{items.join(' ')})"
31
+ end
32
+ end
15
33
 
16
- protected :assert_args, :_by_symbol, :_values, :to_s
34
+ private
35
+
36
+ def assert_args(num)
37
+ c = @operands.size
38
+ raise "Too many (#{c}) operands for '#{@op}' (#{num} expected)" if c > num
39
+ raise "Too few (#{c}) operands for '#{@op}' (#{num} expected)" if c < num
40
+ end
41
+
42
+ def _by_symbol(pos, fact)
43
+ o = @operands[pos]
44
+ raise "A symbol expected at ##{pos}, but '#{o}' (#{o.class}) provided" unless o.is_a?(Symbol)
45
+ k = o.to_s
46
+ fact[k]
47
+ end
48
+
49
+ # @return [Array|nil] Either array of values or NIL
50
+ def _values(pos, fact, maps, fb)
51
+ v = @operands[pos]
52
+ v = v.evaluate(fact, maps, fb) if v.is_a?(Factbase::Term)
53
+ v = v.evaluate(fact, maps, fb) if v.is_a?(Factbase::TermBase)
54
+ v = fact[v.to_s] if v.is_a?(Symbol)
55
+ return v if v.nil?
56
+ unless v.is_a?(Array)
57
+ v =
58
+ if v.respond_to?(:each)
59
+ v.to_a
60
+ else
61
+ [v]
62
+ end
63
+ end
64
+ raise 'Why not array?' unless v.is_a?(Array)
65
+ unless v.all? { |i| [Float, Integer, String, Time, TrueClass, FalseClass].any? { |t| i.is_a?(t) } }
66
+ raise 'Wrong type inside'
67
+ end
68
+ v
69
+ end
17
70
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ # The 'best' term evaluates the best value for a given key.
7
+ class Factbase::Best
8
+ def initialize(&block)
9
+ @criteria = block
10
+ end
11
+
12
+ def evaluate(key, maps)
13
+ raise "A symbol is expected, but #{key} provided" unless key.is_a?(Symbol)
14
+ best = nil
15
+ maps.each do |m|
16
+ vv = m[key.to_s]
17
+ next if vv.nil?
18
+ vv = [vv] unless vv.respond_to?(:to_a)
19
+ vv.each do |v|
20
+ best = v if best.nil? || @criteria.call(v, best)
21
+ end
22
+ end
23
+ best
24
+ end
25
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative 'base'
7
+
8
+ # The 'empty' term checks for emptiness in the results of a query evaluation.
9
+ class Factbase::Empty < Factbase::TermBase
10
+ # Constructor.
11
+ # @param [Array] operands Operands
12
+ def initialize(operands)
13
+ super()
14
+ @operands = operands
15
+ @op = :empty
16
+ end
17
+
18
+ # Evaluate term on a fact.
19
+ # @param [Factbase::Fact] fact The fact
20
+ # @param [Array<Factbase::Fact>] maps All maps available
21
+ # @param [Factbase] fb Factbase to use for sub-queries
22
+ # @return [Boolean] The result of the emptiness check
23
+ def evaluate(fact, maps, fb)
24
+ assert_args(1)
25
+ term = @operands[0]
26
+ unless term.is_a?(Factbase::Term) || term.is_a?(Factbase::TermBase)
27
+ raise "A term is expected, but '#{term}' provided"
28
+ end
29
+ # rubocop:disable Lint/UnreachableLoop
30
+ fb.query(term, maps).each(fb, fact) do
31
+ return false
32
+ end
33
+ # rubocop:enable Lint/UnreachableLoop
34
+ true
35
+ end
36
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative '../../factbase'
7
+ require_relative 'best'
8
+ require_relative 'base'
9
+
10
+ # The 'max' term.
11
+ # This term calculates the max value among the evaluated operands.
12
+ class Factbase::Max < Factbase::TermBase
13
+ MAX = Factbase::Best.new { |v, b| v > b }
14
+
15
+ # Constructor.
16
+ # @param [Array] operands Operands
17
+ def initialize(operands)
18
+ super()
19
+ @operands = operands
20
+ @op = :max
21
+ end
22
+
23
+ # Evaluate term on a fact.
24
+ # @param [Factbase::Fact] _fact The fact
25
+ # @param [Array<Factbase::Fact>] maps All maps available
26
+ # @param [Factbase] _fb Factbase to use for sub-queries
27
+ # @return [Object] The max value among the evaluated operands
28
+ def evaluate(_fact, maps, _fb)
29
+ assert_args(1)
30
+ MAX.evaluate(@operands[0], maps)
31
+ end
32
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative '../../factbase'
7
+ require_relative 'best'
8
+ require_relative 'base'
9
+
10
+ # The 'min' term.
11
+ # This term calculates the minimum value among the evaluated operands.
12
+ class Factbase::Min < Factbase::TermBase
13
+ MIN = Factbase::Best.new { |v, b| v < b }
14
+
15
+ # Constructor.
16
+ # @param [Array] operands Operands
17
+ def initialize(operands)
18
+ super()
19
+ @operands = operands
20
+ @op = :min
21
+ end
22
+
23
+ # Evaluate term on a fact.
24
+ # @param [Factbase::Fact] _fact The fact
25
+ # @param [Array<Factbase::Fact>] maps All maps available
26
+ # @param [Factbase] _fb Factbase to use for sub-queries
27
+ # @return [Object] The minimum value among the evaluated operands
28
+ def evaluate(_fact, maps, _fb)
29
+ assert_args(1)
30
+ MIN.evaluate(@operands[0], maps)
31
+ end
32
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative 'base'
7
+ # Represents an 'nth' term in the Factbase.
8
+ # Retrieves the value of a specified key from the nth map.
9
+ class Factbase::Nth < Factbase::TermBase
10
+ # Constructor.
11
+ # @param [Array] operands Operands
12
+ def initialize(operands)
13
+ super()
14
+ @operands = operands
15
+ @op = :nth
16
+ end
17
+
18
+ # Evaluate term on a fact.
19
+ # @param [Factbase::Fact] _fact The fact
20
+ # @param [Array<Factbase::Fact>] maps All maps available
21
+ # @param [Factbase] _fb Factbase to use for sub-queries
22
+ # @return [Object] The value of the specified key from the nth map
23
+ def evaluate(_fact, maps, _fb)
24
+ assert_args(2)
25
+ pos = @operands[0]
26
+ raise "An integer is expected, but #{pos} provided" unless pos.is_a?(Integer)
27
+ k = @operands[1]
28
+ raise "A symbol is expected, but #{k} provided" unless k.is_a?(Symbol)
29
+ m = maps[pos]
30
+ return nil if m.nil?
31
+ m[k.to_s]
32
+ end
33
+ end
@@ -5,6 +5,7 @@
5
5
 
6
6
  require_relative 'base'
7
7
  require_relative 'boolean'
8
+ require_relative 'simplified'
8
9
  # The 'or' term that represents a logical OR operation between multiple operands.
9
10
  class Factbase::Or < Factbase::TermBase
10
11
  # Constructor.
@@ -25,4 +26,10 @@ class Factbase::Or < Factbase::TermBase
25
26
  end
26
27
  false
27
28
  end
29
+
30
+ def simplify
31
+ unique = Factbase::Simplified.new(@operands).unique
32
+ return unique[0] if unique.size == 1
33
+ Factbase::Term.new(@op, unique)
34
+ end
28
35
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative '../../factbase'
7
+
8
+ # Simplified operands.
9
+ #
10
+ # Author:: Yegor Bugayenko (yegor256@gmail.com)
11
+ # Copyright:: Copyright (c) 2024-2025 Yegor Bugayenko
12
+ # License:: MIT
13
+ class Factbase::Simplified
14
+ def initialize(operands)
15
+ @operands = operands
16
+ end
17
+
18
+ # Removes duplicate operands
19
+ def unique
20
+ strs = []
21
+ ops = []
22
+ @operands.each do |o|
23
+ o = o.simplify
24
+ s = o.to_s
25
+ next if strs.include?(s)
26
+ strs << s
27
+ ops << o
28
+ end
29
+ ops
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative 'base'
7
+
8
+ # This class represents a specialized 'sum' term.
9
+ # This term calculates the sum of values for a specified key.
10
+ class Factbase::Sum < Factbase::TermBase
11
+ # Constructor.
12
+ # @param [Array] operands Operands
13
+ def initialize(operands)
14
+ super()
15
+ @operands = operands
16
+ @op = :sum
17
+ end
18
+
19
+ # Evaluate term on a fact.
20
+ # @param [Factbase::Fact] _fact The fact
21
+ # @param [Array<Factbase::Fact>] maps All maps available
22
+ # @param [Factbase] _fb Factbase to use for sub-queries
23
+ # @return [Integer] The sum of values for the specified key across all maps
24
+ def evaluate(_fact, maps, _fb)
25
+ k = @operands[0]
26
+ raise "A symbol is expected, but '#{k}' provided" unless k.is_a?(Symbol)
27
+ sum = 0
28
+ maps.each do |m|
29
+ vv = m[k.to_s]
30
+ next if vv.nil?
31
+ vv = [vv] unless vv.respond_to?(:to_a)
32
+ vv.each do |v|
33
+ sum += v
34
+ end
35
+ end
36
+ sum
37
+ end
38
+ end
@@ -9,5 +9,5 @@
9
9
  # License:: MIT
10
10
  class Factbase
11
11
  # Current version of the gem (changed by .rultor.yml on every release)
12
- VERSION = '0.17.0' unless const_defined?(:VERSION)
12
+ VERSION = '0.18.0' unless const_defined?(:VERSION)
13
13
  end
data/lib/factbase.rb CHANGED
@@ -164,17 +164,8 @@ class Factbase
164
164
  #
165
165
  # @return [Factbase::Churn] How many facts have been changed (zero if rolled back)
166
166
  def txn
167
- pairs = {}
168
- before =
169
- @maps.map do |m|
170
- n = m.transform_values(&:dup)
171
- # rubocop:disable Lint/HashCompareByIdentity
172
- pairs[n.object_id] = m.object_id
173
- # rubocop:enable Lint/HashCompareByIdentity
174
- n
175
- end
176
- require_relative 'factbase/taped'
177
- taped = Factbase::Taped.new(before)
167
+ require_relative 'factbase/lazy_taped'
168
+ taped = Factbase::LazyTaped.new(@maps)
178
169
  require_relative 'factbase/churn'
179
170
  churn = Factbase::Churn.new
180
171
  catch :commit do
@@ -188,30 +179,32 @@ class Factbase
188
179
  rescue Factbase::Rollback
189
180
  return churn
190
181
  end
191
- seen = []
192
- garbage = []
182
+ seen = {}.compare_by_identity
183
+ garbage = {}.compare_by_identity
184
+ pairs = taped.pairs
193
185
  taped.deleted.each do |oid|
194
- garbage << pairs[oid]
195
- seen << oid
186
+ original = @maps.find { |m| m.object_id == oid }
187
+ next if original.nil?
188
+ garbage[original] = true
196
189
  churn.append(0, 1, 0)
197
190
  end
198
191
  taped.inserted.each do |oid|
199
- next if seen.include?(oid)
200
192
  b = taped.find_by_object_id(oid)
201
193
  next if b.nil?
202
- seen << oid
194
+ next if seen.key?(b)
195
+ seen[b] = true
203
196
  @maps << b
204
197
  churn.append(1, 0, 0)
205
198
  end
206
199
  taped.added.each do |oid|
207
- next if seen.include?(oid)
208
200
  b = taped.find_by_object_id(oid)
209
201
  next if b.nil?
210
- garbage << pairs[oid]
202
+ next if seen.key?(b)
203
+ garbage[pairs[b]] = true
211
204
  @maps << b
212
205
  churn.append(0, 0, 1)
213
206
  end
214
- @maps.delete_if { |m| garbage.include?(m.object_id) } unless garbage.empty?
207
+ @maps.delete_if { |m| garbage.key?(m) } unless garbage.empty?
215
208
  churn
216
209
  end
217
210
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: factbase
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.0
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
@@ -195,6 +195,9 @@ files:
195
195
  - lib/factbase/indexed/indexed_term.rb
196
196
  - lib/factbase/indexed/indexed_unique.rb
197
197
  - lib/factbase/inv.rb
198
+ - lib/factbase/lazy_taped.rb
199
+ - lib/factbase/lazy_taped_array.rb
200
+ - lib/factbase/lazy_taped_hash.rb
198
201
  - lib/factbase/light.rb
199
202
  - lib/factbase/logged.rb
200
203
  - lib/factbase/pre.rb
@@ -208,13 +211,14 @@ files:
208
211
  - lib/factbase/tee.rb
209
212
  - lib/factbase/term.rb
210
213
  - lib/factbase/terms/absent.rb
211
- - lib/factbase/terms/aggregates.rb
214
+ - lib/factbase/terms/agg.rb
212
215
  - lib/factbase/terms/always.rb
213
216
  - lib/factbase/terms/and.rb
214
217
  - lib/factbase/terms/arithmetic.rb
215
218
  - lib/factbase/terms/as.rb
216
219
  - lib/factbase/terms/assert.rb
217
220
  - lib/factbase/terms/base.rb
221
+ - lib/factbase/terms/best.rb
218
222
  - lib/factbase/terms/boolean.rb
219
223
  - lib/factbase/terms/compare.rb
220
224
  - lib/factbase/terms/concat.rb
@@ -222,6 +226,7 @@ files:
222
226
  - lib/factbase/terms/defn.rb
223
227
  - lib/factbase/terms/div.rb
224
228
  - lib/factbase/terms/either.rb
229
+ - lib/factbase/terms/empty.rb
225
230
  - lib/factbase/terms/env.rb
226
231
  - lib/factbase/terms/eq.rb
227
232
  - lib/factbase/terms/exists.rb
@@ -231,23 +236,26 @@ files:
231
236
  - lib/factbase/terms/head.rb
232
237
  - lib/factbase/terms/inverted.rb
233
238
  - lib/factbase/terms/join.rb
234
- - lib/factbase/terms/logical.rb
235
239
  - lib/factbase/terms/lt.rb
236
240
  - lib/factbase/terms/lte.rb
237
241
  - lib/factbase/terms/many.rb
238
242
  - lib/factbase/terms/matches.rb
243
+ - lib/factbase/terms/max.rb
244
+ - lib/factbase/terms/min.rb
239
245
  - lib/factbase/terms/minus.rb
240
246
  - lib/factbase/terms/never.rb
241
247
  - lib/factbase/terms/nil.rb
242
248
  - lib/factbase/terms/not.rb
249
+ - lib/factbase/terms/nth.rb
243
250
  - lib/factbase/terms/one.rb
244
251
  - lib/factbase/terms/or.rb
245
252
  - lib/factbase/terms/plus.rb
246
253
  - lib/factbase/terms/prev.rb
247
- - lib/factbase/terms/shared.rb
254
+ - lib/factbase/terms/simplified.rb
248
255
  - lib/factbase/terms/size.rb
249
256
  - lib/factbase/terms/sorted.rb
250
257
  - lib/factbase/terms/sprintf.rb
258
+ - lib/factbase/terms/sum.rb
251
259
  - lib/factbase/terms/times.rb
252
260
  - lib/factbase/terms/to_float.rb
253
261
  - lib/factbase/terms/to_integer.rb
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
- # SPDX-License-Identifier: MIT
5
-
6
- require_relative '../../factbase'
7
-
8
- # Aggregating terms.
9
- #
10
- # Author:: Yegor Bugayenko (yegor256@gmail.com)
11
- # Copyright:: Copyright (c) 2024-2025 Yegor Bugayenko
12
- # License:: MIT
13
- module Factbase::Aggregates
14
- def min(_fact, maps, _fb)
15
- assert_args(1)
16
- _best(maps) { |v, b| v < b }
17
- end
18
-
19
- def max(_fact, maps, _fb)
20
- assert_args(1)
21
- _best(maps) { |v, b| v > b }
22
- end
23
-
24
- def nth(_fact, maps, _fb)
25
- assert_args(2)
26
- pos = @operands[0]
27
- raise "An integer is expected, but #{pos} provided" unless pos.is_a?(Integer)
28
- k = @operands[1]
29
- raise "A symbol is expected, but #{k} provided" unless k.is_a?(Symbol)
30
- m = maps[pos]
31
- return nil if m.nil?
32
- m[k.to_s]
33
- end
34
-
35
- def sum(_fact, maps, _fb)
36
- k = @operands[0]
37
- raise "A symbol is expected, but '#{k}' provided" unless k.is_a?(Symbol)
38
- sum = 0
39
- maps.each do |m|
40
- vv = m[k.to_s]
41
- next if vv.nil?
42
- vv = [vv] unless vv.respond_to?(:to_a)
43
- vv.each do |v|
44
- sum += v
45
- end
46
- end
47
- sum
48
- end
49
-
50
- def agg(fact, maps, fb)
51
- assert_args(2)
52
- selector = @operands[0]
53
- raise "A term is expected, but '#{selector}' provided" unless selector.is_a?(Factbase::Term)
54
- term = @operands[1]
55
- raise "A term is expected, but '#{term}' provided" unless term.is_a?(Factbase::Term)
56
- subset = fb.query(selector, maps).each(fb, fact).to_a
57
- term.evaluate(nil, subset, fb)
58
- end
59
-
60
- def empty(fact, maps, fb)
61
- assert_args(1)
62
- term = @operands[0]
63
- raise "A term is expected, but '#{term}' provided" unless term.is_a?(Factbase::Term)
64
- # rubocop:disable Lint/UnreachableLoop
65
- fb.query(term, maps).each(fb, fact) do
66
- return false
67
- end
68
- # rubocop:enable Lint/UnreachableLoop
69
- true
70
- end
71
-
72
- def _best(maps)
73
- k = @operands[0]
74
- raise "A symbol is expected, but #{k} provided" unless k.is_a?(Symbol)
75
- best = nil
76
- maps.each do |m|
77
- vv = m[k.to_s]
78
- next if vv.nil?
79
- vv = [vv] unless vv.respond_to?(:to_a)
80
- vv.each do |v|
81
- best = v if best.nil? || yield(v, best)
82
- end
83
- end
84
- best
85
- end
86
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
- # SPDX-License-Identifier: MIT
5
-
6
- require_relative '../../factbase'
7
-
8
- # Logical terms.
9
- #
10
- # Author:: Yegor Bugayenko (yegor256@gmail.com)
11
- # Copyright:: Copyright (c) 2024-2025 Yegor Bugayenko
12
- # License:: MIT
13
- module Factbase::Logical
14
- # Simplifies AND or OR expressions by removing duplicates
15
- # @return [Factbase::Term] Simplified term
16
- def and_or_simplify
17
- strs = []
18
- ops = []
19
- @operands.each do |o|
20
- o = o.simplify
21
- s = o.to_s
22
- next if strs.include?(s)
23
- strs << s
24
- ops << o
25
- end
26
- return ops[0] if ops.size == 1
27
- self.class.new(@op, ops)
28
- end
29
-
30
- # Simplifies AND expressions by removing duplicates
31
- # @return [Factbase::Term] Simplified term
32
- def and_simplify
33
- and_or_simplify
34
- end
35
-
36
- # Simplifies OR expressions by removing duplicates
37
- # @return [Factbase::Term] Simplified term
38
- def or_simplify
39
- and_or_simplify
40
- end
41
- end
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Yegor Bugayenko
4
- # SPDX-License-Identifier: MIT
5
-
6
- # This module provides shared methods for Factbase terms, including argument validation,
7
- # symbol-based lookups, and handling of operand values.
8
- # @todo #302:30min Remove this module and move its methods to Factbase::TermBase.
9
- # Currently, we use it because we are required to inject all thesse methods into Factbase::Term.
10
- # When all the terms will inherit from Factbase::TermBase, we can remove this module.
11
- module Factbase::TermShared
12
- # Turns it into a string.
13
- # @return [String] The string of it
14
- def to_s
15
- @to_s ||=
16
- begin
17
- items = []
18
- items << @op
19
- items +=
20
- @operands.map do |o|
21
- if o.is_a?(String)
22
- "'#{o.gsub("'", "\\\\'").gsub('"', '\\\\"')}'"
23
- elsif o.is_a?(Time)
24
- o.utc.iso8601
25
- else
26
- o.to_s
27
- end
28
- end
29
- "(#{items.join(' ')})"
30
- end
31
- end
32
-
33
- private
34
-
35
- def assert_args(num)
36
- c = @operands.size
37
- raise "Too many (#{c}) operands for '#{@op}' (#{num} expected)" if c > num
38
- raise "Too few (#{c}) operands for '#{@op}' (#{num} expected)" if c < num
39
- end
40
-
41
- def _by_symbol(pos, fact)
42
- o = @operands[pos]
43
- raise "A symbol expected at ##{pos}, but '#{o}' (#{o.class}) provided" unless o.is_a?(Symbol)
44
- k = o.to_s
45
- fact[k]
46
- end
47
-
48
- # @return [Array|nil] Either array of values or NIL
49
- def _values(pos, fact, maps, fb)
50
- v = @operands[pos]
51
- v = v.evaluate(fact, maps, fb) if v.is_a?(Factbase::Term)
52
- v = v.evaluate(fact, maps, fb) if v.is_a?(Factbase::TermBase)
53
- v = fact[v.to_s] if v.is_a?(Symbol)
54
- return v if v.nil?
55
- unless v.is_a?(Array)
56
- v =
57
- if v.respond_to?(:each)
58
- v.to_a
59
- else
60
- [v]
61
- end
62
- end
63
- raise 'Why not array?' unless v.is_a?(Array)
64
- unless v.all? { |i| [Float, Integer, String, Time, TrueClass, FalseClass].any? { |t| i.is_a?(t) } }
65
- raise 'Wrong type inside'
66
- end
67
- v
68
- end
69
- end