factbase 0.0.38 → 0.0.40

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: f3c7e0abf7bec6e53f050b4eeaeddd2b9954ee77b2a5f9a0332394c995d91ba9
4
- data.tar.gz: 5da4b8574d7b1a93fd67f3d833bf51952eb5b3d9c303b9e20a084dc5953d71bc
3
+ metadata.gz: 129d17fba6d19bc5131f853d6b90c2e963187e84626270be4f7766e926d4c2c9
4
+ data.tar.gz: 4196611d541b81bdc1a73bf31f4b1fe15077ad702d8d1ec558f0f61d5e530396
5
5
  SHA512:
6
- metadata.gz: f472603e32966c098979c2a461d94cd60f1f6213c4cea4abae225694d59ae88df252bbd42dcfb3292efb97352618cfa35fe56bef86bf87310dc74b4dbce65a20
7
- data.tar.gz: fb91b271a5d57c59268dd386f8960e1c3c3c1577ea80e5c9bc3b623eaa6c559b15ce326f23ec9035e795d08ce647af353f4ae12aef5a34a124286f41fdf79130
6
+ metadata.gz: 1d87ddb11c2252043c963c6e086a617f216bc15a79441cfa121a0e3de3157f31705cfd1bfea7b6b89996488c76805ee27152f4be00627c7d3f3e1d506636c303
7
+ data.tar.gz: 5e913f0d675f19fc03cc83c80301af5fd390de8cdaf730a0ea2612768ae0056286be7f5f42686616ee0b1fc69cf22adc0009e9df5315251971e9c948da5ee739
data/README.md CHANGED
@@ -54,7 +54,8 @@ f2.import(File.read(file))
54
54
  assert(f2.query('(eq foo 42)').each.to_a.size == 1)
55
55
  ```
56
56
 
57
- There are some terms available in a query:
57
+ There are some boolean terms available in a query
58
+ (they return either TRUE or FALSE):
58
59
 
59
60
  * `(always)` and `(never)` are "true" and "false"
60
61
  * `(not t)` inverses the `t` if it's boolean (exception otherwise)
@@ -66,14 +67,16 @@ There are some terms available in a query:
66
67
  * `(eq a b)` returns true if `a` equals to `b`
67
68
  * `(lt a b)` returns true if `a` is less than `b`
68
69
  * `(gt a b)` returns true if `a` is greater than `b`
69
- * `(size k)` returns cardinality of `k` property (zero if property is absent)
70
- * `(type a)` returns type of `a` ("String", "Integer", "Float", or "Time")
71
70
  * `(many a)` return true if there are many values in the `a` property
72
71
  * `(one a)` returns true if there is only one value in the `a` property
72
+ * `(matches a re)` returns true when `a` matches regular expression `re`
73
+
74
+ There are a few terms that return non-boolean values:
75
+
73
76
  * `(at i a)` returns the `i`-th value of the `a` property
77
+ * `(size k)` returns cardinality of `k` property (zero if property is absent)
78
+ * `(type a)` returns type of `a` ("String", "Integer", "Float", or "Time")
74
79
  * `(either a b)` returns `b` if `a` is `nil`
75
- * `(matches a re)` returns true when `a` matches regular expression `re`
76
- * `(defn foo "self.to_s")` defines a new term using Ruby syntax and returns true
77
80
 
78
81
  Also, some simple arithmetic:
79
82
 
@@ -82,9 +85,14 @@ Also, some simple arithmetic:
82
85
  * `(times a b)` is a multiplication of `a` and `b`
83
86
  * `(div a b)` is a division of `a` by `b`
84
87
 
88
+ One term is for meta-programming:
89
+
90
+ * `(defn foo "self.to_s")` defines a new term using Ruby syntax and returns true
91
+
85
92
  There are terms that are history of search aware:
86
93
 
87
94
  * `(prev a)` returns the value of `a` in the previously seen fact
95
+ * `(unique k)` returns true if the value of `k` property hasn't been seen yet
88
96
 
89
97
  There are also terms that match the entire factbase
90
98
  and must be used inside the `(agg ..)` term:
data/lib/factbase/fact.rb CHANGED
@@ -24,17 +24,20 @@ require 'json'
24
24
  require 'time'
25
25
  require_relative '../factbase'
26
26
 
27
- # Fact.
27
+ # A single fact in a factbase.
28
28
  #
29
- # This is an internal class, it is not supposed to be instantiated directly.
30
- #
31
- # It is possible to use for testing directly, for example to make a
29
+ # This is an internal class, it is not supposed to be instantiated directly,
30
+ # by the +Factbase+ class.
31
+ # However, it is possible to use it for testing directly, for example to make a
32
32
  # fact with a single key/value pair inside:
33
33
  #
34
34
  # require 'factbase/fact'
35
35
  # f = Factbase::Fact.new(Mutex.new, { 'foo' => [42, 256, 'Hello, world!'] })
36
36
  # assert_equal(42, f.foo)
37
37
  #
38
+ # A fact is basically a key/value hash map, where values are non-empty
39
+ # sets of values.
40
+ #
38
41
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
39
42
  # Copyright:: Copyright (c) 2024 Yegor Bugayenko
40
43
  # License:: MIT
@@ -20,6 +20,7 @@
20
20
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  # SOFTWARE.
22
22
 
23
+ require 'time'
23
24
  require 'loog'
24
25
 
25
26
  # A decorator of a Factbase, that logs all operations.
@@ -114,39 +115,51 @@ class Factbase::Looged
114
115
  def each(&)
115
116
  q = Factbase::Syntax.new(@expr).to_term.to_s
116
117
  if block_given?
117
- r = @query.each(&)
118
+ r = nil
119
+ tail = Factbase::Looged.elapsed do
120
+ r = @query.each(&)
121
+ end
118
122
  raise ".each of #{@query.class} returned #{r.class}" unless r.is_a?(Integer)
119
123
  if r.zero?
120
- @loog.debug("Nothing found by '#{q}'")
124
+ @loog.debug("Nothing found by '#{q}' #{tail}")
121
125
  else
122
- @loog.debug("Found #{r} fact(s) by '#{q}'")
126
+ @loog.debug("Found #{r} fact(s) by '#{q}' #{tail}")
123
127
  end
124
128
  r
125
129
  else
126
130
  array = []
127
- # rubocop:disable Style/MapIntoArray
128
- @query.each do |f|
129
- array << f
131
+ tail = Factbase::Looged.elapsed do
132
+ @query.each do |f|
133
+ array << f
134
+ end
130
135
  end
131
- # rubocop:enable Style/MapIntoArray
132
136
  if array.empty?
133
- @loog.debug("Nothing found by '#{q}'")
137
+ @loog.debug("Nothing found by '#{q}' #{tail}")
134
138
  else
135
- @loog.debug("Found #{array.size} fact(s) by '#{q}'")
139
+ @loog.debug("Found #{array.size} fact(s) by '#{q}' #{tail}")
136
140
  end
137
141
  array
138
142
  end
139
143
  end
140
144
 
141
145
  def delete!
142
- r = @query.delete!
146
+ r = nil
147
+ tail = Factbase::Looged.elapsed do
148
+ r = @query.delete!
149
+ end
143
150
  raise ".delete! of #{@query.class} returned #{r.class}" unless r.is_a?(Integer)
144
151
  if r.zero?
145
- @loog.debug("Nothing deleted by '#{@expr}'")
152
+ @loog.debug("Nothing deleted by '#{@expr}' #{tail}")
146
153
  else
147
- @loog.debug("Deleted #{r} fact(s) by '#{@expr}'")
154
+ @loog.debug("Deleted #{r} fact(s) by '#{@expr}' #{tail}")
148
155
  end
149
156
  r
150
157
  end
151
158
  end
159
+
160
+ def self.elapsed
161
+ start = Time.now
162
+ yield
163
+ "in #{format('%.2f', (Time.now - start) * 1000)}ms"
164
+ end
152
165
  end
data/lib/factbase/term.rb CHANGED
@@ -36,6 +36,15 @@ require_relative 'fact'
36
36
  # t = Factbase::Term.new(:lt, [:foo, 50])
37
37
  # assert(t.evaluate(f))
38
38
  #
39
+ # The design of this class may look ugly, since it has a large number of
40
+ # methods, each of which corresponds to a different type of a +Term+. A much
41
+ # better design would definitely involve many classes, one per each type
42
+ # of a term. It's not done this way because of an experimental nature of
43
+ # the project. Most probably we should keep current design intact, since it
44
+ # works well and is rather simple to extend (by adding new term types).
45
+ # Moreover, it looks like the number of possible term types is rather limited
46
+ # and currently we implement most of them.
47
+ #
39
48
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
40
49
  # Copyright:: Copyright (c) 2024 Yegor Bugayenko
41
50
  # License:: MIT
@@ -184,6 +193,19 @@ class Factbase::Term
184
193
  before
185
194
  end
186
195
 
196
+ def unique(fact, _maps)
197
+ @uniques = [] if @uniques.nil?
198
+ assert_args(1)
199
+ vv = by_symbol(0, fact)
200
+ return false if vv.nil?
201
+ vv = [vv] unless vv.is_a?(Array)
202
+ vv.each do |v|
203
+ return false if @uniques.include?(v)
204
+ @uniques << v
205
+ end
206
+ true
207
+ end
208
+
187
209
  def many(fact, maps)
188
210
  assert_args(1)
189
211
  v = the_values(0, fact, maps)
data/lib/factbase.rb CHANGED
@@ -23,9 +23,12 @@
23
23
  require 'json'
24
24
  require 'yaml'
25
25
 
26
- # Factbase.
26
+ # A factbase, which essentially is a NoSQL one-table in-memory database
27
+ # with a Lisp-ish query interface.
27
28
  #
28
- # This is an entry point to a factbase:
29
+ # This class is an entry point to a factbase. For example, this is how you
30
+ # add a new "fact" to a factbase, then put two properties into it, and then
31
+ # find this fact with a simple search.
29
32
  #
30
33
  # fb = Factbase.new
31
34
  # f = fb.insert # new fact created
@@ -34,14 +37,41 @@ require 'yaml'
34
37
  # found = f.query('(gt 20 age)').each.to_a[0]
35
38
  # assert(found.age == 42)
36
39
  #
40
+ # Every fact is a key-value hash map. Every value is a non-empty set of values.
41
+ # Consider this example of creating a factbase with a single fact inside:
42
+ #
43
+ # fb = Factbase.new
44
+ # f = fb.insert
45
+ # f.name = 'Jeff'
46
+ # f.name = 'Walter'
47
+ # f.age = 42
48
+ # f.age = 'unknown'
49
+ # f.place = 'LA'
50
+ # puts f.to_json
51
+ #
52
+ # This will print the following JSON:
53
+ #
54
+ # {
55
+ # 'name': ['Jeff', 'Walter'],
56
+ # 'age': [42, 'unknown'],
57
+ # 'place: 'LA'
58
+ # }
59
+ #
60
+ # Value sets, as you can see, allow data of different types. However, there
61
+ # are only four types are allowed: Integer, Float, String, and Time.
62
+ #
37
63
  # A factbase may be exported to a file and then imported back:
38
64
  #
39
65
  # fb1 = Factbase.new
40
- # File.writebin(file, fb1.export)
66
+ # File.binwrite(file, fb1.export)
41
67
  # fb2 = Factbase.new # it's empty
42
- # fb2.import(File.readbin(file))
68
+ # fb2.import(File.binread(file))
43
69
  #
44
- # It's important to use +writebin+ and +readbin+, because the content is
70
+ # It's impossible to delete properties of a fact. It is however possible to
71
+ # delete the entire fact, with the help of the +query()+ and then +delete!()+
72
+ # methods.
73
+ #
74
+ # It's important to use +binwrite+ and +binread+, because the content is
45
75
  # a chain of bytes, not a text.
46
76
  #
47
77
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
@@ -49,7 +79,10 @@ require 'yaml'
49
79
  # License:: MIT
50
80
  class Factbase
51
81
  # Current version of the gem (changed by .rultor.yml on every release)
52
- VERSION = '0.0.38'
82
+ VERSION = '0.0.40'
83
+
84
+ # An exception that may be thrown in a transaction, to roll it back.
85
+ class Rollback < StandardError; end
53
86
 
54
87
  # Constructor.
55
88
  def initialize(facts = [])
@@ -69,7 +102,13 @@ class Factbase
69
102
  @maps.size
70
103
  end
71
104
 
72
- # Insert a new fact.
105
+ # Insert a new fact and return it.
106
+ #
107
+ # A fact, when inserted, is empty. It doesn't contain any properties.
108
+ #
109
+ # The operation is thread-safe, meaning that you different threads may
110
+ # insert facts parallel without breaking the consistency of the factbase.
111
+ #
73
112
  # @return [Factbase::Fact] The fact just inserted
74
113
  def insert
75
114
  require_relative 'factbase/fact'
@@ -94,6 +133,9 @@ class Factbase
94
133
  # (gt bar 200)
95
134
  # (absent zzz)))
96
135
  #
136
+ # The full list of terms available in the query you can find in the
137
+ # +README.md+ file of the repository.
138
+ #
97
139
  # @param [String] query The query to use for selections
98
140
  def query(query)
99
141
  require_relative 'factbase/query'
@@ -102,9 +144,27 @@ class Factbase
102
144
 
103
145
  # Run an ACID transaction, which will either modify the factbase
104
146
  # or rollback in case of an error.
147
+ #
148
+ # If necessary to terminate a transaction and roolback all changes,
149
+ # you should raise the +Factbase::Rollback+ exception:
150
+ #
151
+ # fb = Factbase.new
152
+ # fb.txn do |fbt|
153
+ # fbt.insert.bar = 42
154
+ # raise Factbase::Rollback
155
+ # end
156
+ #
157
+ # A the end of this script, the factbase will be empty. No facts will
158
+ # inserted and all changes that happened in the block will be rolled back.
159
+ #
160
+ # @param [Factbase] this The factbase to use (don't provide this param)
105
161
  def txn(this = self)
106
162
  copy = this.dup
107
- yield copy
163
+ begin
164
+ yield copy
165
+ rescue Factbase::Rollback
166
+ return
167
+ end
108
168
  @mutex.synchronize do
109
169
  after = Marshal.load(copy.export)
110
170
  after.each_with_index do |m, i|
@@ -117,11 +177,31 @@ class Factbase
117
177
  end
118
178
 
119
179
  # Export it into a chain of bytes.
180
+ #
181
+ # Here is how you can export it to a file, for example:
182
+ #
183
+ # fb = Factbase.new
184
+ # fb.insert.foo = 42
185
+ # File.binwrite("foo.fb", fb.export)
186
+ #
187
+ # The data is binary, it's not a text!
188
+ #
189
+ # @return [Bytes] The chain of bytes
120
190
  def export
121
191
  Marshal.dump(@maps)
122
192
  end
123
193
 
124
194
  # Import from a chain of bytes.
195
+ #
196
+ # Here is how you can read it from a file, for example:
197
+ #
198
+ # fb = Factbase.new
199
+ # fb.import(File.binread("foo.fb"))
200
+ #
201
+ # The facts that existed in the factbase before importing will remain there.
202
+ # The facts from the incoming byte stream will added to them.
203
+ #
204
+ # @param [Bytes] bytes Byte array to import
125
205
  def import(bytes)
126
206
  @maps += Marshal.load(bytes)
127
207
  end
@@ -94,7 +94,7 @@ class TestLooged < Minitest::Test
94
94
  'Found 1 fact(s) by \'(exists bar)\'',
95
95
  'Deleted 3 fact(s) by \'(not (exists bar))\''
96
96
  ].each do |s|
97
- assert(log.to_s.include?("#{s}\n"), "#{log}\n")
97
+ assert(log.to_s.include?(s), "#{log}\n")
98
98
  end
99
99
  end
100
100
  end
@@ -57,6 +57,9 @@ class TestQuery < Minitest::Test
57
57
  '(not (exists hello))' => 3,
58
58
  '(eq "Integer" (type num))' => 2,
59
59
  '(when (eq num 0) (exists time))' => 2,
60
+ '(unique num)' => 2,
61
+ '(unique name)' => 2,
62
+ '(unique pi)' => 1,
60
63
  '(many num)' => 1,
61
64
  '(one num)' => 2,
62
65
  '(gt num (minus 1 (either (at 0 (prev num)) 0)))' => 3,
@@ -82,4 +82,25 @@ class TestTuples < Minitest::Test
82
82
  assert_equal(1, fb.query('(exists bar)').each.to_a.size)
83
83
  assert_equal(1, fb.query('(exists xyz)').each.to_a.size)
84
84
  end
85
+
86
+ def test_with_chaining
87
+ fb = Factbase.new
88
+ f1 = fb.insert
89
+ f1.name = 'Jeff'
90
+ f1.friend = 'Walter'
91
+ f2 = fb.insert
92
+ f2.name = 'Walter'
93
+ f2.group = 1
94
+ f3 = fb.insert
95
+ f3.name = 'Donny'
96
+ f3.group = 1
97
+ tuples = Factbase::Tuples.new(
98
+ fb, ['(eq name "Jeff")', '(eq name "{f0.friend}")', '(eq group {f1.group})']
99
+ )
100
+ tuples.each do |fs|
101
+ assert_equal('Walter', fs[1].name)
102
+ assert(%w[Walter Donny].include?(fs[2].name))
103
+ end
104
+ assert_equal(2, tuples.each.to_a.size)
105
+ end
85
106
  end
@@ -140,4 +140,13 @@ class TestFactbase < Minitest::Test
140
140
  end
141
141
  assert_equal(1, fb.query('(exists xyz)').each.to_a.size)
142
142
  end
143
+
144
+ def test_txn_with_rollback
145
+ fb = Factbase.new
146
+ fb.txn do |fbt|
147
+ fbt.insert.bar = 33
148
+ raise Factbase::Rollback
149
+ end
150
+ assert_equal(0, fb.query('(always)').each.to_a.size)
151
+ end
143
152
  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.38
4
+ version: 0.0.40
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-27 00:00:00.000000000 Z
11
+ date: 2024-05-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json