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 +4 -4
- data/README.md +13 -5
- data/lib/factbase/fact.rb +7 -4
- data/lib/factbase/looged.rb +25 -12
- data/lib/factbase/term.rb +22 -0
- data/lib/factbase.rb +88 -8
- data/test/factbase/test_looged.rb +1 -1
- data/test/factbase/test_query.rb +3 -0
- data/test/factbase/test_tuples.rb +21 -0
- data/test/test_factbase.rb +9 -0
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 129d17fba6d19bc5131f853d6b90c2e963187e84626270be4f7766e926d4c2c9
|
4
|
+
data.tar.gz: 4196611d541b81bdc1a73bf31f4b1fe15077ad702d8d1ec558f0f61d5e530396
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
#
|
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
|
-
#
|
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
|
data/lib/factbase/looged.rb
CHANGED
@@ -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 =
|
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
|
-
|
128
|
-
|
129
|
-
|
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 =
|
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
|
-
#
|
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.
|
66
|
+
# File.binwrite(file, fb1.export)
|
41
67
|
# fb2 = Factbase.new # it's empty
|
42
|
-
# fb2.import(File.
|
68
|
+
# fb2.import(File.binread(file))
|
43
69
|
#
|
44
|
-
# It's
|
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.
|
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
|
-
|
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
|
data/test/factbase/test_query.rb
CHANGED
@@ -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
|
data/test/test_factbase.rb
CHANGED
@@ -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.
|
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-
|
11
|
+
date: 2024-05-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: json
|