factbase 0.0.35 → 0.0.37

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: fc97bfbf967aa07fa189c2d3c696eb8d963f4647c06fb5d97ca4b0e2b4edb4db
4
- data.tar.gz: b38987779b663f76cbf9876b4e117cd1fabb3e1c2ef07bd6ab433be0dede9a44
3
+ metadata.gz: a464f6fa40a3aef098e4ab4a58b6f6905ac101421f22fb806b63c4fb624aa610
4
+ data.tar.gz: ad19a0147890d8b4ff8fff96f7e9f47b09ecb264b3cb52fa606be892278fa380
5
5
  SHA512:
6
- metadata.gz: 97cdc3269d4fbfea8d7246f6cee197be5bdf8b1e463573940b9be2384cbc2718a454e7f1291c5efc11554fc59c84a97c9349def6413d0ee54192118820cdb252
7
- data.tar.gz: '049a1b2ae928bae38e1079f4e67d4f29a3efe3201e27c28c92f58bf4a2eda519a62a9a18095d857792337b19775e319921f8494643718a406bf6c377e36eefa6'
6
+ metadata.gz: 6abb74b759ef7eea421e8a7f4f1ebdbf7ae46836dfefcd324286508fbb82e207a787265c0cf9f8931b43b00d50e5df983266fa9442f9c277111dfa9ea3a778de
7
+ data.tar.gz: b0ed97f83f59230bafce810488f3e720d098a748149f7fbd58b7f1c25425b2fba6b742ce16b18692df34607eb3c42f9f11196ca212bc0273d7bdffe15e759efe
@@ -32,7 +32,7 @@ jobs:
32
32
  strategy:
33
33
  matrix:
34
34
  os: [ubuntu-20.04, macos-12, windows-2022]
35
- ruby: [3.2]
35
+ ruby: [3.2, 3.3]
36
36
  runs-on: ${{ matrix.os }}
37
37
  steps:
38
38
  - uses: actions/checkout@v4
data/Gemfile.lock CHANGED
@@ -101,7 +101,7 @@ GEM
101
101
  zeitwerk (~> 2.6)
102
102
  rainbow (3.1.1)
103
103
  rake (13.2.1)
104
- rdoc (6.6.3.1)
104
+ rdoc (6.7.0)
105
105
  psych (>= 4.0.0)
106
106
  regexp_parser (2.9.2)
107
107
  reline (0.5.7)
@@ -171,7 +171,7 @@ GEM
171
171
  webrick (1.8.1)
172
172
  yaml (0.3.0)
173
173
  yard (0.9.36)
174
- zeitwerk (2.6.14)
174
+ zeitwerk (2.6.15)
175
175
 
176
176
  PLATFORMS
177
177
  arm64-darwin-22
data/README.md CHANGED
@@ -54,7 +54,7 @@ f2.import(File.read(file))
54
54
  assert(f2.query('(eq foo 42)').each.to_a.size == 1)
55
55
  ```
56
56
 
57
- All terms available in a query:
57
+ There are some terms available in a query:
58
58
 
59
59
  * `(always)` and `(never)` are "true" and "false"
60
60
  * `(not t)` inverses the `t` if it's boolean (exception otherwise)
@@ -68,14 +68,39 @@ All terms available in a query:
68
68
  * `(gt a b)` returns true if `a` is greater than `b`
69
69
  * `(size k)` returns cardinality of `k` property (zero if property is absent)
70
70
  * `(type a)` returns type of `a` ("String", "Integer", "Float", or "Time")
71
+ * `(many a)` return true if there are many values in the `a` property
72
+ * `(one a)` returns true if there is only one value in the `a` property
73
+ * `(at i a)` returns the `i`-th value of the `a` property
74
+ * `(nonil a b)` returns `b` if `a` is `nil`
71
75
  * `(matches a re)` returns true when `a` matches regular expression `re`
72
76
  * `(defn foo "self.to_s")` defines a new term using Ruby syntax and returns true
73
77
 
74
- There are also terms that match the entire factbase:
78
+ Also, some simple arithmetic:
75
79
 
76
- * `(max k)` returns true if the value of `k` property
77
- is the largest in the entire factbase
78
- * `(min k)` returns true if the value of `k` is the smallest
80
+ * `(plus a b)` is a sum of `a` and `b`
81
+ * `(minus a b)` is a deducation of `b` from `a`
82
+ * `(times a b)` is a multiplication of `a` and `b`
83
+ * `(div a b)` is a division of `a` by `b`
84
+
85
+ There are terms that are history of search aware:
86
+
87
+ * `(prev a)` returns the value of `a` in the previously seen fact
88
+
89
+ There are also terms that match the entire factbase
90
+ and must be used inside the `(agg ..)` term:
91
+
92
+ * `(count)` returns the tally of facts
93
+ * `(max k)` returns the maximum value of the `k` property in all facts
94
+ * `(min k)` returns the minimum
95
+ * `(sum k)` returns the arithmetic sum of all values of the `k` property
96
+
97
+ The `agg` term enables sub-queries by evaluating the first argument (term)
98
+ over all available facts, passing the entire subset to the second argument,
99
+ and then returning the result as an atomic value:
100
+
101
+ * `(lt age (agg (eq gender 'F') (max age)))` selects all facts where
102
+ the `age` is smaller than the maximum `age` of all women
103
+ * `(eq id (agg (always) (max id)))` selects the fact with the largest `id`
79
104
 
80
105
  ## How to contribute
81
106
 
data/factbase.gemspec CHANGED
@@ -25,17 +25,21 @@ require_relative 'lib/factbase'
25
25
 
26
26
  Gem::Specification.new do |s|
27
27
  s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to? :required_rubygems_version=
28
- s.required_ruby_version = '>=2.3'
28
+ s.required_ruby_version = '>=3.0'
29
29
  s.name = 'factbase'
30
30
  s.version = Factbase::VERSION
31
31
  s.license = 'MIT'
32
32
  s.summary = 'Factbase'
33
- s.description = 'Fact base in memory and on disc'
33
+ s.description =
34
+ 'A primitive in-memory collection of key-value records ' \
35
+ 'known as "facts," with an ability to insert facts, add properties ' \
36
+ 'to facts, and delete facts. There is no ability to modify facts. ' \
37
+ 'It is also possible to find facts using Lisp-alike query predicates. ' \
38
+ 'An entire factbase may be exported to a binary file and imported back.'
34
39
  s.authors = ['Yegor Bugayenko']
35
40
  s.email = 'yegor256@gmail.com'
36
41
  s.homepage = 'http://github.com/yegor256/factbase.rb'
37
42
  s.files = `git ls-files`.split($RS)
38
- s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
39
43
  s.rdoc_options = ['--charset=UTF-8']
40
44
  s.extra_rdoc_files = ['README.md', 'LICENSE.txt']
41
45
  s.add_runtime_dependency 'json', '~> 2.7'
data/lib/factbase/fact.rb CHANGED
@@ -28,10 +28,20 @@ require_relative '../factbase'
28
28
  #
29
29
  # This is an internal class, it is not supposed to be instantiated directly.
30
30
  #
31
+ # It is possible to use for testing directly, for example to make a
32
+ # fact with a single key/value pair inside:
33
+ #
34
+ # require 'factbase/fact'
35
+ # f = Factbase::Fact.new(Mutex.new, { 'foo' => [42, 256, 'Hello, world!'] })
36
+ # assert_equal(42, f.foo)
37
+ #
31
38
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
32
39
  # Copyright:: Copyright (c) 2024 Yegor Bugayenko
33
40
  # License:: MIT
34
41
  class Factbase::Fact
42
+ # Ctor.
43
+ # @param [Mutex] mutex A mutex to use for maps synchronization
44
+ # @param [Hash] map A map of key/value pairs
35
45
  def initialize(mutex, map)
36
46
  @mutex = mutex
37
47
  @map = map
@@ -48,7 +58,7 @@ class Factbase::Fact
48
58
  k = args[0].to_s
49
59
  if k.end_with?('=')
50
60
  kk = k[0..-2]
51
- raise "Invalid prop name '#{kk}'" unless kk.match?(/^[a-z][_a-zA-Z0-9]*$/)
61
+ raise "Invalid prop name '#{kk}'" unless kk.match?(/^[a-z_][_a-zA-Z0-9]*$/)
52
62
  raise "Prohibited prop name '#{kk}'" if kk == 'to_s'
53
63
  v = args[1]
54
64
  raise "Prop value can't be nil" if v.nil?
@@ -129,14 +129,14 @@ class Factbase::Syntax
129
129
  elsif t.start_with?('\'', '"')
130
130
  raise 'String literal can\'t be empty' if t.length <= 2
131
131
  t[1..-2]
132
- elsif t.match?(/^[0-9]+$/)
132
+ elsif t.match?(/^(\+|-)?[0-9]+$/)
133
133
  t.to_i
134
- elsif t.match?(/^[0-9]+\.[0-9]+(e\+[0-9]+)?$/)
134
+ elsif t.match?(/^(\+|-)?[0-9]+\.[0-9]+(e\+[0-9]+)?$/)
135
135
  t.to_f
136
136
  elsif t.match?(/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/)
137
137
  Time.parse(t)
138
138
  else
139
- raise "Wrong symbol format (#{t})" unless t.match?(/^[a-z][a-zA-Z0-9_]*$/)
139
+ raise "Wrong symbol format (#{t})" unless t.match?(/^[_a-z][a-zA-Z0-9_]*$/)
140
140
  t.to_sym
141
141
  end
142
142
  end
data/lib/factbase/term.rb CHANGED
@@ -27,6 +27,15 @@ require_relative 'fact'
27
27
  #
28
28
  # This is an internal class, it is not supposed to be instantiated directly.
29
29
  #
30
+ # It is possible to use for testing directly, for example to make a
31
+ # term with two arguments:
32
+ #
33
+ # require 'factbase/fact'
34
+ # require 'factbase/term'
35
+ # f = Factbase::Fact.new(Mutex.new, { 'foo' => [42, 256, 'Hello, world!'] })
36
+ # t = Factbase::Term.new(:lt, [:foo, 50])
37
+ # assert(t.evaluate(f))
38
+ #
30
39
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
31
40
  # Copyright:: Copyright (c) 2024 Yegor Bugayenko
32
41
  # License:: MIT
@@ -93,19 +102,19 @@ class Factbase::Term
93
102
 
94
103
  def not(fact, maps)
95
104
  assert_args(1)
96
- !only_bool(the_value(0, fact, maps))
105
+ !only_bool(the_values(0, fact, maps))
97
106
  end
98
107
 
99
108
  def or(fact, maps)
100
109
  (0..@operands.size - 1).each do |i|
101
- return true if only_bool(the_value(i, fact, maps))
110
+ return true if only_bool(the_values(i, fact, maps))
102
111
  end
103
112
  false
104
113
  end
105
114
 
106
115
  def and(fact, maps)
107
116
  (0..@operands.size - 1).each do |i|
108
- return false unless only_bool(the_value(i, fact, maps))
117
+ return false unless only_bool(the_values(i, fact, maps))
109
118
  end
110
119
  true
111
120
  end
@@ -149,16 +158,70 @@ class Factbase::Term
149
158
  by_symbol(0, fact).nil?
150
159
  end
151
160
 
161
+ def nonil(fact, maps)
162
+ assert_args(2)
163
+ v = the_values(0, fact, maps)
164
+ return v unless v.nil?
165
+ the_values(1, fact, maps)
166
+ end
167
+
168
+ def at(fact, maps)
169
+ assert_args(2)
170
+ i = the_values(0, fact, maps)
171
+ raise 'Too many values at first position, one expected' unless i.size == 1
172
+ i = i[0]
173
+ return nil if i.nil?
174
+ v = the_values(1, fact, maps)
175
+ return nil if v.nil?
176
+ v[i]
177
+ end
178
+
179
+ def prev(fact, maps)
180
+ assert_args(1)
181
+ before = @prev
182
+ v = the_values(0, fact, maps)
183
+ @prev = v
184
+ before
185
+ end
186
+
187
+ def many(fact, maps)
188
+ assert_args(1)
189
+ v = the_values(0, fact, maps)
190
+ !v.nil? && v.size > 1
191
+ end
192
+
193
+ def one(fact, maps)
194
+ assert_args(1)
195
+ v = the_values(0, fact, maps)
196
+ !v.nil? && v.size == 1
197
+ end
198
+
199
+ def plus(fact, maps)
200
+ arithmetic(:+, fact, maps)
201
+ end
202
+
203
+ def minus(fact, maps)
204
+ arithmetic(:-, fact, maps)
205
+ end
206
+
207
+ def times(fact, maps)
208
+ arithmetic(:*, fact, maps)
209
+ end
210
+
211
+ def div(fact, maps)
212
+ arithmetic(:/, fact, maps)
213
+ end
214
+
152
215
  def eq(fact, maps)
153
- arithmetic(:==, fact, maps)
216
+ cmp(:==, fact, maps)
154
217
  end
155
218
 
156
219
  def lt(fact, maps)
157
- arithmetic(:<, fact, maps)
220
+ cmp(:<, fact, maps)
158
221
  end
159
222
 
160
223
  def gt(fact, maps)
161
- arithmetic(:>, fact, maps)
224
+ cmp(:>, fact, maps)
162
225
  end
163
226
 
164
227
  def size(fact, _maps)
@@ -178,20 +241,20 @@ class Factbase::Term
178
241
 
179
242
  def matches(fact, maps)
180
243
  assert_args(2)
181
- str = the_value(0, fact, maps)
244
+ str = the_values(0, fact, maps)
182
245
  return false if str.nil?
183
246
  raise 'Exactly one string expected' unless str.size == 1
184
- re = the_value(1, fact, maps)
247
+ re = the_values(1, fact, maps)
185
248
  raise 'Regexp is nil' if re.nil?
186
249
  raise 'Exactly one regexp expected' unless re.size == 1
187
250
  str[0].to_s.match?(re[0])
188
251
  end
189
252
 
190
- def arithmetic(op, fact, maps)
253
+ def cmp(op, fact, maps)
191
254
  assert_args(2)
192
- lefts = the_value(0, fact, maps)
255
+ lefts = the_values(0, fact, maps)
193
256
  return false if lefts.nil?
194
- rights = the_value(1, fact, maps)
257
+ rights = the_values(1, fact, maps)
195
258
  return false if rights.nil?
196
259
  lefts.any? do |l|
197
260
  l = l.floor if l.is_a?(Time) && op == :==
@@ -202,6 +265,17 @@ class Factbase::Term
202
265
  end
203
266
  end
204
267
 
268
+ def arithmetic(op, fact, maps)
269
+ assert_args(2)
270
+ lefts = the_values(0, fact, maps)
271
+ raise 'The first argument is NIL, while literal expected' if lefts.nil?
272
+ raise 'Too many values at first position, one expected' unless lefts.size == 1
273
+ rights = the_values(1, fact, maps)
274
+ raise 'The second argument is NIL, while literal expected' if rights.nil?
275
+ raise 'Too many values at second position, one expected' unless rights.size == 1
276
+ lefts[0].send(op, rights[0])
277
+ end
278
+
205
279
  def defn(_fact, _maps)
206
280
  fn = @operands[0]
207
281
  raise 'A symbol expected as first argument of defn' unless fn.is_a?(Symbol)
@@ -269,7 +343,7 @@ class Factbase::Term
269
343
  fact[k]
270
344
  end
271
345
 
272
- def the_value(pos, fact, maps)
346
+ def the_values(pos, fact, maps)
273
347
  v = @operands[pos]
274
348
  v = v.evaluate(fact, maps) if v.is_a?(Factbase::Term)
275
349
  v = fact[v.to_s] if v.is_a?(Symbol)
data/lib/factbase.rb CHANGED
@@ -24,12 +24,32 @@ require 'json'
24
24
  require 'yaml'
25
25
 
26
26
  # Factbase.
27
+ #
28
+ # This is an entry point to a factbase:
29
+ #
30
+ # fb = Factbase.new
31
+ # f = fb.insert # new fact created
32
+ # f.name = 'Jeff Lebowski'
33
+ # f.age = 42
34
+ # found = f.query('(gt 20 age)').each.to_a[0]
35
+ # assert(found.age == 42)
36
+ #
37
+ # A factbase may be exported to a file and then imported back:
38
+ #
39
+ # fb1 = Factbase.new
40
+ # File.writebin(file, fb1.export)
41
+ # fb2 = Factbase.new # it's empty
42
+ # fb2.import(File.readbin(file))
43
+ #
44
+ # It's important to use +writebin+ and +readbin+, because the content is
45
+ # a chain of bytes, not a text.
46
+ #
27
47
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
28
48
  # Copyright:: Copyright (c) 2024 Yegor Bugayenko
29
49
  # License:: MIT
30
50
  class Factbase
31
51
  # Current version of the gem (changed by .rultor.yml on every release)
32
- VERSION = '0.0.35'
52
+ VERSION = '0.0.37'
33
53
 
34
54
  # Constructor.
35
55
  def initialize(facts = [])
@@ -80,8 +80,8 @@ class TestFact < Minitest::Test
80
80
 
81
81
  def test_set_by_name
82
82
  f = Factbase::Fact.new(Mutex.new, {})
83
- f.send('foo_bar=', 42)
84
- assert_equal(42, f.foo_bar, f.to_s)
83
+ f.send('_foo_bar=', 42)
84
+ assert_equal(42, f._foo_bar, f.to_s)
85
85
  end
86
86
 
87
87
  def test_set_twice_same_value
@@ -53,13 +53,21 @@ class TestQuery < Minitest::Test
53
53
  '(gt num 60)' => 1,
54
54
  "(and (lt pi 100) \n\n (gt num 1000))" => 0,
55
55
  '(exists pi)' => 1,
56
+ '(eq pi +3.14)' => 1,
56
57
  '(not (exists hello))' => 3,
57
58
  '(eq "Integer" (type num))' => 2,
58
59
  '(when (eq num 0) (exists time))' => 2,
60
+ '(many num)' => 1,
61
+ '(one num)' => 2,
62
+ '(gt num (minus 1 (nonil (at 0 (prev num)) 0)))' => 3,
63
+ '(and (not (many num)) (eq num (plus 21 +21)))' => 1,
64
+ '(and (not (many num)) (eq num (minus -100 -142)))' => 1,
65
+ '(and (one num) (eq num (times 7 6)))' => 1,
66
+ '(and (one pi) (eq pi (div -6.28 -2)))' => 1,
59
67
  '(gt (size num) 2)' => 1,
60
68
  '(matches name "^[a-z]+$")' => 1,
61
69
  '(lt (size num) 2)' => 2,
62
- '(eq (size hello) 0)' => 3,
70
+ '(eq (size _hello) 0)' => 3,
63
71
  '(eq num pi)' => 0,
64
72
  '(absent time)' => 2,
65
73
  '(eq pi (agg (eq num 0) (sum pi)))' => 1,
@@ -68,7 +76,7 @@ class TestQuery < Minitest::Test
68
76
  '(eq time (min time))' => 1,
69
77
  '(and (absent time) (exists pi))' => 1,
70
78
  "(and (exists time) (not (\t\texists pi)))" => 1,
71
- "(or (eq num 66) (lt time #{(Time.now - 200).utc.iso8601}))" => 1
79
+ "(or (eq num +66) (lt time #{(Time.now - 200).utc.iso8601}))" => 1
72
80
  }.each do |q, r|
73
81
  assert_equal(r, Factbase::Query.new(maps, Mutex.new, q).each.to_a.size, q)
74
82
  end
@@ -166,6 +166,23 @@ class TestTerm < Minitest::Test
166
166
  assert_equal('(foo \'hello, world!\')', t1.evaluate(fact, []))
167
167
  end
168
168
 
169
+ def test_past
170
+ t = Factbase::Term.new(:prev, [:foo])
171
+ assert_nil(t.evaluate(fact('foo' => 4), []))
172
+ assert_equal([4], t.evaluate(fact('foo' => 5), []))
173
+ end
174
+
175
+ def test_at
176
+ t = Factbase::Term.new(:at, [1, :foo])
177
+ assert_nil(t.evaluate(fact('foo' => 4), []))
178
+ assert_equal(5, t.evaluate(fact('foo' => [4, 5]), []))
179
+ end
180
+
181
+ def test_nonil
182
+ t = Factbase::Term.new(:nonil, [Factbase::Term.new(:at, [5, :foo]), 42])
183
+ assert_equal([42], t.evaluate(fact('foo' => 4), []))
184
+ end
185
+
169
186
  private
170
187
 
171
188
  def fact(map = {})
@@ -49,8 +49,8 @@ class TestFactbase < Minitest::Test
49
49
  f2 = Factbase.new
50
50
  f1.insert.foo = 42
51
51
  Tempfile.open do |f|
52
- File.write(f.path, f1.export)
53
- f2.import(File.read(f.path))
52
+ File.binwrite(f.path, f1.export)
53
+ f2.import(File.binread(f.path))
54
54
  end
55
55
  assert_equal(1, f2.query('(eq foo 42)').each.to_a.count)
56
56
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: factbase
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.35
4
+ version: 0.0.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-05-26 00:00:00.000000000 Z
11
+ date: 2024-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -66,7 +66,11 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0.3'
69
- description: Fact base in memory and on disc
69
+ description: A primitive in-memory collection of key-value records known as "facts,"
70
+ with an ability to insert facts, add properties to facts, and delete facts. There
71
+ is no ability to modify facts. It is also possible to find facts using Lisp-alike
72
+ query predicates. An entire factbase may be exported to a binary file and imported
73
+ back.
70
74
  email: yegor256@gmail.com
71
75
  executables: []
72
76
  extensions: []
@@ -138,7 +142,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
138
142
  requirements:
139
143
  - - ">="
140
144
  - !ruby/object:Gem::Version
141
- version: '2.3'
145
+ version: '3.0'
142
146
  required_rubygems_version: !ruby/object:Gem::Requirement
143
147
  requirements:
144
148
  - - ">="