factbase 0.19.10 → 0.19.12
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/Gemfile +5 -4
- data/Gemfile.lock +18 -14
- data/README.md +127 -20
- data/Rakefile +2 -7
- data/factbase.gemspec +11 -11
- data/lib/factbase/accum.rb +1 -1
- data/lib/factbase/cached/cached_fact.rb +1 -2
- data/lib/factbase/cached/cached_factbase.rb +3 -3
- data/lib/factbase/cached/cached_query.rb +4 -6
- data/lib/factbase/cached/cached_term.rb +1 -2
- data/lib/factbase/churn.rb +4 -8
- data/lib/factbase/fact.rb +12 -9
- data/lib/factbase/flatten.rb +2 -2
- data/lib/factbase/impatient.rb +14 -13
- data/lib/factbase/indexed/indexed_and.rb +14 -20
- data/lib/factbase/indexed/indexed_eq.rb +5 -1
- data/lib/factbase/indexed/indexed_fact.rb +1 -4
- data/lib/factbase/indexed/indexed_factbase.rb +4 -4
- data/lib/factbase/indexed/indexed_gt.rb +3 -1
- data/lib/factbase/indexed/indexed_gte.rb +51 -0
- data/lib/factbase/indexed/indexed_lt.rb +3 -1
- data/lib/factbase/indexed/indexed_lte.rb +51 -0
- data/lib/factbase/indexed/indexed_not.rb +1 -1
- data/lib/factbase/indexed/indexed_or.rb +2 -2
- data/lib/factbase/indexed/indexed_query.rb +6 -7
- data/lib/factbase/indexed/indexed_term.rb +10 -6
- data/lib/factbase/indexed/indexed_unique.rb +4 -2
- data/lib/factbase/inv.rb +3 -3
- data/lib/factbase/lazy_taped.rb +10 -13
- data/lib/factbase/lazy_taped_hash.rb +2 -1
- data/lib/factbase/light.rb +1 -1
- data/lib/factbase/logged.rb +37 -34
- data/lib/factbase/pre.rb +3 -3
- data/lib/factbase/query.rb +4 -5
- data/lib/factbase/rules.rb +8 -8
- data/lib/factbase/sync/sync_factbase.rb +2 -2
- data/lib/factbase/syntax.rb +19 -20
- data/lib/factbase/tallied.rb +7 -8
- data/lib/factbase/taped.rb +5 -11
- data/lib/factbase/tee.rb +2 -2
- data/lib/factbase/term.rb +58 -59
- data/lib/factbase/terms/agg.rb +3 -4
- data/lib/factbase/terms/arithmetic.rb +7 -7
- data/lib/factbase/terms/as.rb +2 -2
- data/lib/factbase/terms/assert.rb +5 -13
- data/lib/factbase/terms/base.rb +7 -10
- data/lib/factbase/terms/best.rb +1 -1
- data/lib/factbase/terms/boolean.rb +1 -1
- data/lib/factbase/terms/compare.rb +17 -1
- data/lib/factbase/terms/contains.rb +28 -0
- data/lib/factbase/terms/defn.rb +8 -6
- data/lib/factbase/terms/empty.rb +1 -1
- data/lib/factbase/terms/ends_with.rb +27 -0
- data/lib/factbase/terms/first.rb +2 -2
- data/lib/factbase/terms/head.rb +3 -3
- data/lib/factbase/terms/inverted.rb +2 -2
- data/lib/factbase/terms/join.rb +8 -7
- data/lib/factbase/terms/matches.rb +14 -4
- data/lib/factbase/terms/max.rb +1 -1
- data/lib/factbase/terms/min.rb +1 -1
- data/lib/factbase/terms/nth.rb +3 -3
- data/lib/factbase/terms/plus.rb +1 -1
- data/lib/factbase/terms/prev.rb +3 -6
- data/lib/factbase/terms/sorted.rb +2 -2
- data/lib/factbase/terms/sprintf.rb +11 -2
- data/lib/factbase/terms/starts_with.rb +27 -0
- data/lib/factbase/terms/sum.rb +2 -2
- data/lib/factbase/terms/to_float.rb +2 -2
- data/lib/factbase/terms/to_integer.rb +2 -2
- data/lib/factbase/terms/to_string.rb +1 -1
- data/lib/factbase/terms/to_time.rb +10 -2
- data/lib/factbase/terms/traced.rb +2 -2
- data/lib/factbase/terms/undef.rb +2 -2
- data/lib/factbase/terms/unique.rb +3 -7
- data/lib/factbase/terms/when.rb +2 -3
- data/lib/factbase/to_json.rb +2 -2
- data/lib/factbase/to_xml.rb +6 -10
- data/lib/factbase/to_yaml.rb +1 -1
- data/lib/factbase/version.rb +1 -2
- data/lib/factbase.rb +27 -13
- data/lib/fuzz.rb +3 -3
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f97152f34dd9eb3ba72cc5380a87d321bbec59c316078108b38c2c078c23a0fd
|
|
4
|
+
data.tar.gz: 0f678a254dc256d1983965742771c37d0956a89f93ec91e098f63b4d2957117a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a8ebb9cd303ab0a6b34842ec06fa2d5f4ed1ec1d2c8fff89e1068531782386c1c6da2bba4f2d116635ca37532d314c25626fe0fdcac03a10ead6b57871483485
|
|
7
|
+
data.tar.gz: 1f2be490a544b5417059853ed135011c85215bbea7fb9b96551bcd67badc32e20a9384acf97d6a3b4908e63332937bb4502df168c79aa19ed95afc1a364ee4bd
|
data/Gemfile
CHANGED
|
@@ -10,16 +10,17 @@ gem 'benchmark', '~>0.5', require: false
|
|
|
10
10
|
gem 'minitest', '~>6.0', require: false
|
|
11
11
|
gem 'minitest-reporters', '~>1.7', require: false
|
|
12
12
|
gem 'os', '~>1.1', require: false
|
|
13
|
-
gem 'psych', '5.3.1', require: false
|
|
13
|
+
gem 'psych', '5.3.1', require: false
|
|
14
14
|
gem 'qbash', '~>0.4', require: false
|
|
15
15
|
gem 'rake', '~>13.2', require: false
|
|
16
|
-
gem 'rdoc', '7.1.0', require: false
|
|
16
|
+
gem 'rdoc', '7.1.0', require: false
|
|
17
17
|
gem 'rubocop', '~>1.74', require: false
|
|
18
|
+
gem 'rubocop-elegant', '~>0.5', require: false
|
|
18
19
|
gem 'rubocop-minitest', '~>0.38', require: false
|
|
19
20
|
gem 'rubocop-performance', '~>1.25', require: false
|
|
20
21
|
gem 'rubocop-rake', '~>0.7', require: false
|
|
21
22
|
gem 'simplecov', '~>0.22', require: false
|
|
22
23
|
gem 'simplecov-cobertura', '~>3.0', require: false
|
|
23
|
-
gem 'stackprof', '0.2.27', require: false, platforms: [:ruby]
|
|
24
|
+
gem 'stackprof', '0.2.27', require: false, platforms: [:ruby]
|
|
24
25
|
gem 'threads', '~>0.4', require: false
|
|
25
|
-
gem 'yard', '0.9.38', require: false
|
|
26
|
+
gem 'yard', '0.9.38', require: false
|
data/Gemfile.lock
CHANGED
|
@@ -30,15 +30,15 @@ GEM
|
|
|
30
30
|
loog (~> 0.6)
|
|
31
31
|
tago (~> 0.1)
|
|
32
32
|
ellipsized (0.3.0)
|
|
33
|
-
erb (6.0.
|
|
34
|
-
json (2.
|
|
33
|
+
erb (6.0.4)
|
|
34
|
+
json (2.19.5)
|
|
35
35
|
language_server-protocol (3.17.0.5)
|
|
36
36
|
lint_roller (1.1.0)
|
|
37
37
|
logger (1.7.0)
|
|
38
38
|
loog (0.8.0)
|
|
39
39
|
ellipsized
|
|
40
40
|
logger (~> 1.0)
|
|
41
|
-
minitest (6.0.
|
|
41
|
+
minitest (6.0.6)
|
|
42
42
|
drb (~> 2.0)
|
|
43
43
|
prism (~> 1.5)
|
|
44
44
|
minitest-reporters (1.8.0)
|
|
@@ -46,25 +46,25 @@ GEM
|
|
|
46
46
|
builder
|
|
47
47
|
minitest (>= 5.0, < 7)
|
|
48
48
|
ruby-progressbar
|
|
49
|
-
nokogiri (1.19.
|
|
49
|
+
nokogiri (1.19.3-arm64-darwin)
|
|
50
50
|
racc (~> 1.4)
|
|
51
|
-
nokogiri (1.19.
|
|
51
|
+
nokogiri (1.19.3-x64-mingw-ucrt)
|
|
52
52
|
racc (~> 1.4)
|
|
53
|
-
nokogiri (1.19.
|
|
53
|
+
nokogiri (1.19.3-x86_64-darwin)
|
|
54
54
|
racc (~> 1.4)
|
|
55
|
-
nokogiri (1.19.
|
|
55
|
+
nokogiri (1.19.3-x86_64-linux-gnu)
|
|
56
56
|
racc (~> 1.4)
|
|
57
57
|
os (1.1.4)
|
|
58
58
|
others (0.1.1)
|
|
59
|
-
parallel (1.
|
|
60
|
-
parser (3.3.
|
|
59
|
+
parallel (2.1.0)
|
|
60
|
+
parser (3.3.11.1)
|
|
61
61
|
ast (~> 2.4.1)
|
|
62
62
|
racc
|
|
63
63
|
prism (1.9.0)
|
|
64
64
|
psych (5.3.1)
|
|
65
65
|
date
|
|
66
66
|
stringio
|
|
67
|
-
qbash (0.8.
|
|
67
|
+
qbash (0.8.4)
|
|
68
68
|
backtrace (> 0)
|
|
69
69
|
elapsed (> 0)
|
|
70
70
|
loog (> 0)
|
|
@@ -76,22 +76,25 @@ GEM
|
|
|
76
76
|
erb
|
|
77
77
|
psych (>= 4.0.0)
|
|
78
78
|
tsort
|
|
79
|
-
regexp_parser (2.
|
|
79
|
+
regexp_parser (2.12.0)
|
|
80
80
|
rexml (3.4.4)
|
|
81
|
-
rubocop (1.
|
|
81
|
+
rubocop (1.86.2)
|
|
82
82
|
json (~> 2.3)
|
|
83
83
|
language_server-protocol (~> 3.17.0.2)
|
|
84
84
|
lint_roller (~> 1.1.0)
|
|
85
|
-
parallel (
|
|
85
|
+
parallel (>= 1.10)
|
|
86
86
|
parser (>= 3.3.0.2)
|
|
87
87
|
rainbow (>= 2.2.2, < 4.0)
|
|
88
88
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
89
89
|
rubocop-ast (>= 1.49.0, < 2.0)
|
|
90
90
|
ruby-progressbar (~> 1.7)
|
|
91
91
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
92
|
-
rubocop-ast (1.49.
|
|
92
|
+
rubocop-ast (1.49.1)
|
|
93
93
|
parser (>= 3.3.7.2)
|
|
94
94
|
prism (~> 1.7)
|
|
95
|
+
rubocop-elegant (0.5.1)
|
|
96
|
+
lint_roller (~> 1.1)
|
|
97
|
+
rubocop (~> 1.75)
|
|
95
98
|
rubocop-minitest (0.39.1)
|
|
96
99
|
lint_roller (~> 1.1)
|
|
97
100
|
rubocop (>= 1.75.0, < 2.0)
|
|
@@ -148,6 +151,7 @@ DEPENDENCIES
|
|
|
148
151
|
rake (~> 13.2)
|
|
149
152
|
rdoc (= 7.1.0)
|
|
150
153
|
rubocop (~> 1.74)
|
|
154
|
+
rubocop-elegant (~> 0.5)
|
|
151
155
|
rubocop-minitest (~> 0.38)
|
|
152
156
|
rubocop-performance (~> 1.25)
|
|
153
157
|
rubocop-rake (~> 0.7)
|
data/README.md
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
[](https://rubydoc.info/github/yegor256/factbase/master/frames)
|
|
12
12
|
[](https://hitsofcode.com/view/github/yegor256/factbase)
|
|
13
13
|
[](https://github.com/yegor256/factbase/blob/master/LICENSE.txt)
|
|
14
|
-
[](https://app.fossa.com/projects/git%2Bgithub
|
|
14
|
+
[](https://app.fossa.com/projects/git%2Bgithub%2Fyegor256%2Ffactbase?ref=badge_shield&issueType=license)
|
|
15
|
+
|
|
16
|
+
> **WARNING:** `Factbase` is **NOT** thread-safe. Use `Factbase::SyncFactbase`
|
|
17
|
+
> for concurrent access from multiple threads.
|
|
15
18
|
|
|
16
19
|
This Ruby gem manages an in-memory database of facts.
|
|
17
20
|
A fact is simply an associative array of properties and their values.
|
|
@@ -19,7 +22,8 @@ The values are either atomic literals or non-empty sets of literals.
|
|
|
19
22
|
It is possible to delete a fact, but impossible to delete a property
|
|
20
23
|
from a fact.
|
|
21
24
|
|
|
22
|
-
Here is how you use it (it
|
|
25
|
+
Here is how you use it (wrap it with `Factbase::SyncFactbase`
|
|
26
|
+
for thread-safe access):
|
|
23
27
|
|
|
24
28
|
```ruby
|
|
25
29
|
fb = Factbase.new
|
|
@@ -119,6 +123,27 @@ end
|
|
|
119
123
|
assert(0 == fb.size)
|
|
120
124
|
```
|
|
121
125
|
|
|
126
|
+
## Query Parameters
|
|
127
|
+
|
|
128
|
+
You can use `$name` variables in queries and pass values at runtime:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
fb = Factbase.new
|
|
132
|
+
fb.insert.foo = 42
|
|
133
|
+
fb.insert.foo = 99
|
|
134
|
+
fb.query('(eq foo $bar)').each(Factbase.new, bar: [42]).to_a
|
|
135
|
+
# Returns the fact with foo = 42
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Variables are passed as keyword arguments to `each` or `one`. The value must
|
|
139
|
+
be an array (even for a single value). This works with any term:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
fb.query(
|
|
143
|
+
'(eq salary (agg (eq dept $dept) (avg salary)))'
|
|
144
|
+
).each(Factbase.new, dept: ['eng']).to_a
|
|
145
|
+
```
|
|
146
|
+
|
|
122
147
|
## Terms
|
|
123
148
|
|
|
124
149
|
There are some boolean terms available in a query
|
|
@@ -129,8 +154,7 @@ There are some boolean terms available in a query
|
|
|
129
154
|
* `(not b)` is the inverse of `b`
|
|
130
155
|
* `(or b1 b2 ...)` is `true` if at least one argument is `true`
|
|
131
156
|
* `(and b1 b2 ...)` — if all arguments are `true`
|
|
132
|
-
* `(when b1 b2)` — if `b1` is `
|
|
133
|
-
or `b1` is `false`
|
|
157
|
+
* `(when b1 b2)` — true if `b1` is `false`, or if both `b1` and `b2` are `true`
|
|
134
158
|
* `(exists p)` — if `p` property exists
|
|
135
159
|
* `(absent p)` — if `p` property is absent
|
|
136
160
|
* `(zero v)` — if any `v` equals to zero
|
|
@@ -145,6 +169,9 @@ There are string manipulators:
|
|
|
145
169
|
* `(concat v1 v2 v3 ...)` — concatenates all `v`
|
|
146
170
|
* `(sprintf v v1 v2 ...)` — creates a string by `v` format with params
|
|
147
171
|
* `(matches v s)` — if any `v` matches the `s` regular expression
|
|
172
|
+
* `(contains v s)` — if any value of `v` contains any value of `s` (substring match)
|
|
173
|
+
* `(starts_with v s)` — if any `v` starts with `s`
|
|
174
|
+
* `(ends_with v s)` — if any `v` ends with `s`
|
|
148
175
|
|
|
149
176
|
There are a few terms that return non-boolean values:
|
|
150
177
|
|
|
@@ -152,7 +179,7 @@ There are a few terms that return non-boolean values:
|
|
|
152
179
|
* `(size v)` is the cardinality of `v` (zero if `v` is `nil`)
|
|
153
180
|
* `(type v)` is the type of `v`
|
|
154
181
|
(`"String"`, `"Integer"`, `"Float"`, `"Time"`, or `"Array"`)
|
|
155
|
-
* `(either v1
|
|
182
|
+
* `(either v1 v2)` is `v2` if `v1` is `nil`
|
|
156
183
|
|
|
157
184
|
It's possible to modify the facts retrieved, on fly:
|
|
158
185
|
|
|
@@ -227,6 +254,86 @@ There are some system-level terms:
|
|
|
227
254
|
* `(env v1 v2)` returns the value of environment variable `v1` or the string
|
|
228
255
|
`v2` if it's not set
|
|
229
256
|
|
|
257
|
+
## Architecture
|
|
258
|
+
|
|
259
|
+
The entire database is a single flat [Ruby](https://www.ruby-lang.org/en/)
|
|
260
|
+
`Array` of `Hash` objects held in RAM (`Factbase#@maps`). There are no
|
|
261
|
+
tables, schemas, or type enforcement beyond four scalar types: `Integer`,
|
|
262
|
+
`Float`, `String`, and `Time`. This contrasts with
|
|
263
|
+
[SQLite](https://sqlite.org/) (fixed-column tables on disk) and
|
|
264
|
+
[MongoDB](https://www.mongodb.com/) (typed document collections). New
|
|
265
|
+
programmers must understand that all data vanishes on process exit unless
|
|
266
|
+
`export`/`import` is called explicitly.
|
|
267
|
+
|
|
268
|
+
Each property of a fact is a non-empty ordered set of values rather than a
|
|
269
|
+
single value. Assigning `f.foo = 1` then `f.foo = 2` produces
|
|
270
|
+
`f['foo'] == [1, 2]`; each assignment appends. Reading `f.foo` returns
|
|
271
|
+
the first element; `f['foo']` returns the full array. This accumulative
|
|
272
|
+
semantics differs from [SQL](https://www.iso.org/standard/76583.html)
|
|
273
|
+
(one value per column) and most NoSQL stores where assignment overwrites.
|
|
274
|
+
New programmers must expect multi-element arrays on every property read.
|
|
275
|
+
|
|
276
|
+
Queries use a custom Lisp-style
|
|
277
|
+
[S-expression](https://en.wikipedia.org/wiki/S-expression) language:
|
|
278
|
+
`(and (eq kind 'book') (gt age 10))`. `Factbase::Syntax` tokenizes and
|
|
279
|
+
parses a query string into an AST of `Factbase::Term` objects;
|
|
280
|
+
`Factbase::Query#each` evaluates that AST against every fact. This
|
|
281
|
+
differs from [SQL](https://www.iso.org/standard/76583.html),
|
|
282
|
+
[XPath](https://www.w3.org/TR/xpath-31/), and
|
|
283
|
+
[JSONPath](https://datatracker.ietf.org/doc/html/rfc9535). New
|
|
284
|
+
programmers add operators by implementing a term class, not by modifying
|
|
285
|
+
parser grammar.
|
|
286
|
+
|
|
287
|
+
Each query operator (`eq`, `gt`, `agg`, `join`, etc.) is a separate class
|
|
288
|
+
under `lib/factbase/terms/`. `Factbase::Term` holds a dispatch hash
|
|
289
|
+
(`@terms`) mapping operator symbols to instances and delegates `evaluate`
|
|
290
|
+
and `predict` calls there. This is not a class hierarchy — adding a new
|
|
291
|
+
operator requires a new file in `terms/` and a registration line in the
|
|
292
|
+
`Factbase::Term` constructor. New programmers extending the query
|
|
293
|
+
language must follow this two-step pattern.
|
|
294
|
+
|
|
295
|
+
Transactions are ACID and implemented via lazy copy-on-write journaling.
|
|
296
|
+
`Factbase#txn` wraps the array in `Factbase::LazyTaped`, which defers
|
|
297
|
+
physical duplication of hash objects until the first write. Inserts,
|
|
298
|
+
deletes, and property additions are tracked by Ruby `object_id`. On
|
|
299
|
+
commit the journal is replayed into the main array; raising
|
|
300
|
+
`Factbase::Rollback` discards it. Nesting transactions is explicitly
|
|
301
|
+
forbidden by `Factbase::Light`. This differs from SQLite's
|
|
302
|
+
[WAL](https://sqlite.org/wal.html) and PostgreSQL's
|
|
303
|
+
[MVCC](https://www.postgresql.org/docs/current/mvcc.html).
|
|
304
|
+
|
|
305
|
+
Cross-cutting capabilities — thread safety, indexing, constraint
|
|
306
|
+
validation, logging, and change counting — are added via decorators:
|
|
307
|
+
`Factbase::SyncFactbase`, `Factbase::IndexedFactbase`,
|
|
308
|
+
`Factbase::Rules`, `Factbase::Logged`, and `Factbase::Tallied`. The
|
|
309
|
+
[`decoor`](https://github.com/yegor256/decoor) gem provides delegation
|
|
310
|
+
boilerplate. The bare `Factbase` class is not thread-safe; new
|
|
311
|
+
programmers must wrap it with `SyncFactbase` before sharing across
|
|
312
|
+
threads.
|
|
313
|
+
|
|
314
|
+
Persistence uses Ruby's
|
|
315
|
+
[`Marshal`](https://ruby-doc.org/core/Marshal.html), serializing the
|
|
316
|
+
internal array of hashes to a binary blob via `Marshal.dump`. The format
|
|
317
|
+
is Ruby-version-specific and not portable across major Ruby versions or
|
|
318
|
+
platforms, unlike [JSON](https://www.json.org/json-en.html) or
|
|
319
|
+
[Protocol Buffers](https://protobuf.dev/). Output-only decorators
|
|
320
|
+
`Factbase::ToJSON`, `Factbase::ToXML`, and `Factbase::ToYAML` exist but
|
|
321
|
+
do not support round-trip import. Because `import` calls `Marshal.load`
|
|
322
|
+
on the incoming bytes, the input must come from a source the caller
|
|
323
|
+
trusts; a `Marshal` stream crafted by an attacker can execute arbitrary
|
|
324
|
+
code in the calling process, so factbase blobs received over the
|
|
325
|
+
network or read from a user-supplied path should be authenticated
|
|
326
|
+
out-of-band before being imported, as described in the
|
|
327
|
+
[Ruby security notes](https://docs.ruby-lang.org/en/3.3/security_rdoc.html#label-Marshal.load).
|
|
328
|
+
|
|
329
|
+
`Factbase::IndexedFactbase` lazily builds a hash-based inverted index for
|
|
330
|
+
equality queries, keyed by array `object_id`, property name, and
|
|
331
|
+
operator. The index is built incrementally on each query and invalidated
|
|
332
|
+
entirely on any mutation (delete or property addition). Without this
|
|
333
|
+
decorator every `query#each` call performs a full linear scan over all
|
|
334
|
+
facts. New programmers should add `IndexedFactbase` whenever the
|
|
335
|
+
factbase holds more than a few thousand facts.
|
|
336
|
+
|
|
230
337
|
## How to contribute
|
|
231
338
|
|
|
232
339
|
Read
|
|
@@ -250,24 +357,24 @@ This is the result of the benchmark:
|
|
|
250
357
|
<!-- benchmark_begin -->
|
|
251
358
|
```text
|
|
252
359
|
user
|
|
253
|
-
void scan 0.
|
|
254
|
-
20k facts: export:
|
|
255
|
-
20k facts: import:
|
|
256
|
-
50k facts: read 0.
|
|
257
|
-
50k facts: read in txn 0.
|
|
258
|
-
50k facts: insert 0.
|
|
259
|
-
50k facts: insert in txn 0.
|
|
260
|
-
50k facts: modify 1.
|
|
261
|
-
50k facts: modify in txn
|
|
262
|
-
12k facts: large query: match 3k
|
|
263
|
-
12k facts: large query: match 3k in txn
|
|
264
|
-
12k facts: large query: match zero
|
|
265
|
-
12k facts: large query: match zero in txn
|
|
360
|
+
void scan 0.001115
|
|
361
|
+
20k facts: export: 2979KB 0.979531
|
|
362
|
+
20k facts: import: 2979KB 1.055363
|
|
363
|
+
50k facts: read 0.000212
|
|
364
|
+
50k facts: read in txn 0.001724
|
|
365
|
+
50k facts: insert 0.000081
|
|
366
|
+
50k facts: insert in txn 0.000226
|
|
367
|
+
50k facts: modify 1.604103
|
|
368
|
+
50k facts: modify in txn 3.126437
|
|
369
|
+
12k facts: large query: match 3k 13.220472
|
|
370
|
+
12k facts: large query: match 3k in txn 18.484006
|
|
371
|
+
12k facts: large query: match zero 13.730576
|
|
372
|
+
12k facts: large query: match zero in txn 19.485743
|
|
266
373
|
```
|
|
267
374
|
|
|
268
375
|
The results were calculated in [this GHA job][benchmark-gha]
|
|
269
|
-
on 2026-
|
|
376
|
+
on 2026-06-14 at 16:40,
|
|
270
377
|
on Linux with 4 CPUs.
|
|
271
378
|
<!-- benchmark_end -->
|
|
272
379
|
|
|
273
|
-
[benchmark-gha]: https://github.com/yegor256/factbase/actions/runs/
|
|
380
|
+
[benchmark-gha]: https://github.com/yegor256/factbase/actions/runs/27505290212
|
data/Rakefile
CHANGED
|
@@ -63,7 +63,7 @@ end
|
|
|
63
63
|
desc 'Benchmark them all'
|
|
64
64
|
task :benchmark, [:name, :cycles] do |_t, args|
|
|
65
65
|
bname = args[:name] || 'essential'
|
|
66
|
-
cycles = (args[:cycles] || 5)
|
|
66
|
+
cycles = Integer(args[:cycles] || 5)
|
|
67
67
|
require_relative 'lib/factbase'
|
|
68
68
|
require_relative 'lib/factbase/cached/cached_factbase'
|
|
69
69
|
require_relative 'lib/factbase/indexed/indexed_factbase'
|
|
@@ -84,16 +84,11 @@ task :benchmark, [:name, :cycles] do |_t, args|
|
|
|
84
84
|
fb = Factbase.new
|
|
85
85
|
fb = Factbase::IndexedFactbase.new(fb)
|
|
86
86
|
fb = Factbase::CachedFactbase.new(fb)
|
|
87
|
-
Kernel.
|
|
87
|
+
Kernel.__send__(File.basename(f).gsub(/\.rb$/, '').to_sym, b, fb, cycles)
|
|
88
88
|
end
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
# Run profiling on a benchmark and generate a flamegraph.
|
|
93
|
-
# To run this task, you need to have stackprof installed.
|
|
94
|
-
# https://github.com/tmm1/stackprof
|
|
95
|
-
# To run profiling for a specific benchmark you can run:
|
|
96
|
-
# bundle exec rake flamegraph\[bench_slow_query\]
|
|
97
92
|
desc 'Profile a benchmark (e.g., flamegraph[bench_slow_query])'
|
|
98
93
|
task :flamegraph, [:name] do |_t, args|
|
|
99
94
|
require 'stackprof'
|
data/factbase.gemspec
CHANGED
|
@@ -7,7 +7,7 @@ require 'English'
|
|
|
7
7
|
require_relative 'lib/factbase/version'
|
|
8
8
|
|
|
9
9
|
Gem::Specification.new do |s|
|
|
10
|
-
s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to?
|
|
10
|
+
s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to?(:required_rubygems_version=)
|
|
11
11
|
s.required_ruby_version = '>=3.0'
|
|
12
12
|
s.name = 'factbase'
|
|
13
13
|
s.version = Factbase::VERSION
|
|
@@ -25,15 +25,15 @@ Gem::Specification.new do |s|
|
|
|
25
25
|
s.files = `git ls-files | grep -v -E '^(test/|\\.|fixtures|benchmark|renovate)'`.split($RS)
|
|
26
26
|
s.rdoc_options = ['--charset=UTF-8']
|
|
27
27
|
s.extra_rdoc_files = ['README.md', 'LICENSE.txt']
|
|
28
|
-
s.add_dependency
|
|
29
|
-
s.add_dependency
|
|
30
|
-
s.add_dependency
|
|
31
|
-
s.add_dependency
|
|
32
|
-
s.add_dependency
|
|
33
|
-
s.add_dependency
|
|
34
|
-
s.add_dependency
|
|
35
|
-
s.add_dependency
|
|
36
|
-
s.add_dependency
|
|
37
|
-
s.add_dependency
|
|
28
|
+
s.add_dependency('backtrace', '~>0.4')
|
|
29
|
+
s.add_dependency('decoor', '~>0.1')
|
|
30
|
+
s.add_dependency('ellipsized', '~>0.3')
|
|
31
|
+
s.add_dependency('json', '~>2.7')
|
|
32
|
+
s.add_dependency('logger', '~>1.0')
|
|
33
|
+
s.add_dependency('loog', '~>0.6')
|
|
34
|
+
s.add_dependency('nokogiri', '~>1.10')
|
|
35
|
+
s.add_dependency('others', '~>0.1')
|
|
36
|
+
s.add_dependency('tago', '~>0.1')
|
|
37
|
+
s.add_dependency('yaml', '~>0.3')
|
|
38
38
|
s.metadata['rubygems_mfa_required'] = 'true'
|
|
39
39
|
end
|
data/lib/factbase/accum.rb
CHANGED
|
@@ -22,9 +22,9 @@ class Factbase::CachedFactbase
|
|
|
22
22
|
# @param [Factbase] origin Original factbase to decorate
|
|
23
23
|
# @param [Hash] cache Cache to use
|
|
24
24
|
def initialize(origin, cache = {})
|
|
25
|
-
raise 'Wrong type of original' unless origin.respond_to?(:query)
|
|
25
|
+
raise(ArgumentError, 'Wrong type of original') unless origin.respond_to?(:query)
|
|
26
26
|
@origin = origin
|
|
27
|
-
raise 'Wrong type of cache' unless cache.is_a?(Hash)
|
|
27
|
+
raise(ArgumentError, 'Wrong type of cache') unless cache.is_a?(Hash)
|
|
28
28
|
@cache = cache
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -58,7 +58,7 @@ class Factbase::CachedFactbase
|
|
|
58
58
|
# @return [Factbase::Churn] How many facts have been changed (zero if rolled back)
|
|
59
59
|
def txn
|
|
60
60
|
@origin.txn do |fbt|
|
|
61
|
-
yield
|
|
61
|
+
yield(Factbase::CachedFactbase.new(fbt, @cache))
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
end
|
|
@@ -36,13 +36,12 @@ class Factbase::CachedQuery
|
|
|
36
36
|
def each(fb = @fb, params = {})
|
|
37
37
|
return to_enum(__method__, fb, params) unless block_given?
|
|
38
38
|
invalidate_if_dirty!
|
|
39
|
-
key = "each #{@origin}"
|
|
40
|
-
|
|
41
|
-
@cache[key] = @origin.each(fb, params).to_a if before.nil?
|
|
39
|
+
key = "each #{@origin}"
|
|
40
|
+
@cache[key] = @origin.each(fb, params).to_a if @cache[key].nil?
|
|
42
41
|
c = 0
|
|
43
42
|
@cache[key].each do |f|
|
|
44
43
|
c += 1
|
|
45
|
-
yield
|
|
44
|
+
yield(Factbase::CachedFact.new(f, @cache))
|
|
46
45
|
end
|
|
47
46
|
c
|
|
48
47
|
end
|
|
@@ -54,8 +53,7 @@ class Factbase::CachedQuery
|
|
|
54
53
|
def one(fb = @fb, params = {})
|
|
55
54
|
invalidate_if_dirty!
|
|
56
55
|
key = "one: #{@origin} #{params}"
|
|
57
|
-
|
|
58
|
-
@cache[key] = @origin.one(fb, params) if before.nil?
|
|
56
|
+
@cache[key] = @origin.one(fb, params) if @cache[key].nil?
|
|
59
57
|
@cache[key]
|
|
60
58
|
end
|
|
61
59
|
|
|
@@ -19,8 +19,7 @@ module Factbase::CachedTerm
|
|
|
19
19
|
return super unless static? && !abstract?
|
|
20
20
|
return super if %i[head unique].include?(@op)
|
|
21
21
|
key = [maps.object_id, to_s]
|
|
22
|
-
|
|
23
|
-
@cache[key] = super if before.nil?
|
|
22
|
+
@cache[key] = super if @cache[key].nil?
|
|
24
23
|
@cache[key]
|
|
25
24
|
end
|
|
26
25
|
end
|
data/lib/factbase/churn.rb
CHANGED
|
@@ -22,16 +22,16 @@ class Factbase::Churn
|
|
|
22
22
|
if zero?
|
|
23
23
|
'nothing'
|
|
24
24
|
else
|
|
25
|
-
"#{
|
|
25
|
+
"#{inserted}i/#{deleted}d/#{added}a"
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def zero?
|
|
30
|
-
|
|
30
|
+
inserted.zero? && deleted.zero? && added.zero?
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def to_i
|
|
34
|
-
|
|
34
|
+
inserted + deleted + added
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def append(ins, del, add)
|
|
@@ -43,10 +43,6 @@ class Factbase::Churn
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def +(other)
|
|
46
|
-
Factbase::Churn.new(
|
|
47
|
-
@inserted + other.inserted,
|
|
48
|
-
@deleted + other.deleted,
|
|
49
|
-
@added + other.added
|
|
50
|
-
)
|
|
46
|
+
Factbase::Churn.new(inserted + other.inserted, deleted + other.deleted, added + other.added)
|
|
51
47
|
end
|
|
52
48
|
end
|
data/lib/factbase/fact.rb
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# SPDX-License-Identifier: MIT
|
|
5
5
|
|
|
6
6
|
require 'json'
|
|
7
|
-
require 'time'
|
|
8
7
|
require 'others'
|
|
8
|
+
require 'time'
|
|
9
9
|
require_relative '../factbase'
|
|
10
10
|
|
|
11
11
|
# A single fact in a factbase.
|
|
@@ -37,7 +37,7 @@ class Factbase::Fact
|
|
|
37
37
|
# Convert it to a string.
|
|
38
38
|
# @return [String] String representation of it (in JSON)
|
|
39
39
|
def to_s
|
|
40
|
-
"[ #{@map.map { |k, v| "#{k}: #{v}" }.sort
|
|
40
|
+
"[ #{@map.map { |k, v| "#{k}: #{v}" }.sort!.join(', ')} ]"
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# Get a list of all props.
|
|
@@ -50,12 +50,15 @@ class Factbase::Fact
|
|
|
50
50
|
k = args[0].to_s
|
|
51
51
|
if k.end_with?('=')
|
|
52
52
|
kk = k[0..-2]
|
|
53
|
-
raise "Invalid prop name '#{kk}'" unless kk.match?(/^[a-z_][_a-zA-Z0-9]*$/)
|
|
54
|
-
raise "Prohibited prop name '#{kk}'" if methods.include?(kk.to_sym)
|
|
53
|
+
raise(ArgumentError, "Invalid prop name '#{kk}'") unless kk.match?(/^[a-z_][_a-zA-Z0-9]*$/)
|
|
54
|
+
raise(ArgumentError, "Prohibited prop name '#{kk}'") if methods.include?(kk.to_sym)
|
|
55
55
|
v = args[1]
|
|
56
|
-
raise "The value of '#{kk}' can't be nil" if v.nil?
|
|
57
|
-
raise "The value of '#{kk}' can't be empty" if v == ''
|
|
58
|
-
raise "The type '#{v.class}' of '#{kk}' is not allowed" unless [
|
|
56
|
+
raise(ArgumentError, "The value of '#{kk}' can't be nil") if v.nil?
|
|
57
|
+
raise(ArgumentError, "The value of '#{kk}' can't be empty") if v == ''
|
|
58
|
+
raise(ArgumentError, "The type '#{v.class}' of '#{kk}' is not allowed") unless [
|
|
59
|
+
String, Integer, Float,
|
|
60
|
+
Time
|
|
61
|
+
].include?(v.class)
|
|
59
62
|
v = v.utc if v.is_a?(Time)
|
|
60
63
|
@map[kk] = [] if @map[kk].nil?
|
|
61
64
|
@map[kk] << v
|
|
@@ -65,8 +68,8 @@ class Factbase::Fact
|
|
|
65
68
|
else
|
|
66
69
|
v = @map[k]
|
|
67
70
|
if v.nil?
|
|
68
|
-
raise "Can't get '#{k}', the fact is empty" if @map.empty?
|
|
69
|
-
raise "Can't find '#{k}' attribute out of [#{@map.keys.join(', ')}]"
|
|
71
|
+
raise(ArgumentError, "Can't get '#{k}', the fact is empty") if @map.empty?
|
|
72
|
+
raise(ArgumentError, "Can't find '#{k}' attribute out of [#{@map.keys.join(', ')}]")
|
|
70
73
|
end
|
|
71
74
|
v[0]
|
|
72
75
|
end
|
data/lib/factbase/flatten.rb
CHANGED
|
@@ -21,8 +21,8 @@ class Factbase::Flatten
|
|
|
21
21
|
# @return [Array<HashMap>] The hashmaps, but improved
|
|
22
22
|
def it
|
|
23
23
|
@maps
|
|
24
|
-
.sort_by { |m| m[@sorter]
|
|
24
|
+
.sort_by { |m| Array(m[@sorter]).map(&:to_s) }
|
|
25
25
|
.map { |m| m.sort.to_h }
|
|
26
|
-
.map { |m| m.transform_values { |v| v.size == 1 ? v[0] : v } }
|
|
26
|
+
.map! { |m| m.transform_values { |v| v.size == 1 ? v[0] : v } }
|
|
27
27
|
end
|
|
28
28
|
end
|
data/lib/factbase/impatient.rb
CHANGED
|
@@ -18,9 +18,9 @@ class Factbase::Impatient
|
|
|
18
18
|
# @param [Factbase] fb The factbase to decorate
|
|
19
19
|
# @param [Integer] timeout Timeout in seconds
|
|
20
20
|
def initialize(fb, timeout: 15)
|
|
21
|
-
raise 'The "fb" is nil' if fb.nil?
|
|
21
|
+
raise(ArgumentError, 'The "fb" is nil') if fb.nil?
|
|
22
22
|
@origin = fb
|
|
23
|
-
@timeout = timeout
|
|
23
|
+
@timeout = Float(timeout)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
decoor(:origin)
|
|
@@ -36,7 +36,7 @@ class Factbase::Impatient
|
|
|
36
36
|
|
|
37
37
|
def txn
|
|
38
38
|
@origin.txn do |fbt|
|
|
39
|
-
yield
|
|
39
|
+
yield(Factbase::Impatient.new(fbt, timeout: @timeout))
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -56,17 +56,15 @@ class Factbase::Impatient
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def each(fb = @fb, params = {}, &)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
return to_enum(__method__, fb, params) unless block_given?
|
|
60
|
+
n = 0
|
|
61
|
+
impatient('each') do
|
|
62
|
+
@fb.query(@term, @maps).each(fb, params) do |f|
|
|
63
|
+
yield(f)
|
|
64
|
+
n += 1
|
|
62
65
|
end
|
|
63
|
-
return a unless block_given?
|
|
64
|
-
yielded = 0
|
|
65
|
-
a.each do |f|
|
|
66
|
-
yield f
|
|
67
|
-
yielded += 1
|
|
68
66
|
end
|
|
69
|
-
|
|
67
|
+
n
|
|
70
68
|
end
|
|
71
69
|
|
|
72
70
|
def one(fb = @fb, params = {})
|
|
@@ -86,7 +84,10 @@ class Factbase::Impatient
|
|
|
86
84
|
def impatient(name, &)
|
|
87
85
|
Timeout.timeout(@timeout, &)
|
|
88
86
|
rescue Timeout::Error => e
|
|
89
|
-
raise
|
|
87
|
+
raise(
|
|
88
|
+
StandardError,
|
|
89
|
+
"#{name}() timed out after #{@timeout.seconds} (#{e.message}), fb size is #{@fb.size}: #{@term}"
|
|
90
|
+
)
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
end
|