factbase 0.19.10 → 0.19.11

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: 4b573b12e8a04a0b69a2e4c33c6ccd17c21c1c14a52a644c87564738388de546
4
- data.tar.gz: b4c888226e04aee8054ff26d056cc04f34fd3a1fb29455ff6c77ce4dd2103d45
3
+ metadata.gz: 712cd682bc9457b563afc7a855e56cad191d2a302406e0e4e9f886ce7fa83af3
4
+ data.tar.gz: cd3a312b78a152b7c9631777473e7465648e6b57d31d713d37d93c63834048ed
5
5
  SHA512:
6
- metadata.gz: a96ff08cc40deb0d17e25cc7c30f8e67d5c09b0179e623d7e941ade194ac7c52c2135730fb4a813faf7370097a1d151db74e1559de81a619bf572becb1eec408
7
- data.tar.gz: 1f57d718a82c2c8d107c033c3d8b59440170822f5810fadfc3b99837cf4072493e96ec8a39bff41c73a913cc3dba75837a9f4ecc0804976da37ebe02c09b1777
6
+ metadata.gz: 013ff487bbd8d2379224361b7794d5219d3235c9c66f645ebe1d6dd928d2298f1a411240328839ea48e08f7dd4c1161cde173530c52f5f8a5afdab52c66cce98
7
+ data.tar.gz: cf57ff6a8f881224a2124306871d245cab380331905fd0ec1acecd381a46d636a7877867a8f1ef27611f1dafe4f1e777c1a01aa57f08b35eb3486682aef22ad3
data/Gemfile.lock CHANGED
@@ -38,7 +38,7 @@ GEM
38
38
  loog (0.8.0)
39
39
  ellipsized
40
40
  logger (~> 1.0)
41
- minitest (6.0.5)
41
+ minitest (6.0.6)
42
42
  drb (~> 2.0)
43
43
  prism (~> 1.5)
44
44
  minitest-reporters (1.8.0)
@@ -64,7 +64,7 @@ GEM
64
64
  psych (5.3.1)
65
65
  date
66
66
  stringio
67
- qbash (0.8.3)
67
+ qbash (0.8.4)
68
68
  backtrace (> 0)
69
69
  elapsed (> 0)
70
70
  loog (> 0)
data/README.md CHANGED
@@ -129,8 +129,7 @@ There are some boolean terms available in a query
129
129
  * `(not b)` is the inverse of `b`
130
130
  * `(or b1 b2 ...)` is `true` if at least one argument is `true`
131
131
  * `(and b1 b2 ...)` — if all arguments are `true`
132
- * `(when b1 b2)` — if `b1` is `true` and `b2` is `true`
133
- or `b1` is `false`
132
+ * `(when b1 b2)` — true if `b1` is `false`, or if both `b1` and `b2` are `true`
134
133
  * `(exists p)` — if `p` property exists
135
134
  * `(absent p)` — if `p` property is absent
136
135
  * `(zero v)` — if any `v` equals to zero
@@ -145,6 +144,9 @@ There are string manipulators:
145
144
  * `(concat v1 v2 v3 ...)` — concatenates all `v`
146
145
  * `(sprintf v v1 v2 ...)` — creates a string by `v` format with params
147
146
  * `(matches v s)` — if any `v` matches the `s` regular expression
147
+ * `(contains v s)` — if any value of `v` contains any value of `s` (substring match)
148
+ * `(starts_with v s)` — if any `v` starts with `s`
149
+ * `(ends_with v s)` — if any `v` ends with `s`
148
150
 
149
151
  There are a few terms that return non-boolean values:
150
152
 
@@ -152,7 +154,7 @@ There are a few terms that return non-boolean values:
152
154
  * `(size v)` is the cardinality of `v` (zero if `v` is `nil`)
153
155
  * `(type v)` is the type of `v`
154
156
  (`"String"`, `"Integer"`, `"Float"`, `"Time"`, or `"Array"`)
155
- * `(either v1 v1)` is `v2` if `v1` is `nil`
157
+ * `(either v1 v2)` is `v2` if `v1` is `nil`
156
158
 
157
159
  It's possible to modify the facts retrieved, on fly:
158
160
 
@@ -227,6 +229,80 @@ There are some system-level terms:
227
229
  * `(env v1 v2)` returns the value of environment variable `v1` or the string
228
230
  `v2` if it's not set
229
231
 
232
+ ## Architecture
233
+
234
+ The entire database is a single flat [Ruby](https://www.ruby-lang.org/en/)
235
+ `Array` of `Hash` objects held in RAM (`Factbase#@maps`). There are no
236
+ tables, schemas, or type enforcement beyond four scalar types: `Integer`,
237
+ `Float`, `String`, and `Time`. This contrasts with
238
+ [SQLite](https://sqlite.org/) (fixed-column tables on disk) and
239
+ [MongoDB](https://www.mongodb.com/) (typed document collections). New
240
+ programmers must understand that all data vanishes on process exit unless
241
+ `export`/`import` is called explicitly.
242
+
243
+ Each property of a fact is a non-empty ordered set of values rather than a
244
+ single value. Assigning `f.foo = 1` then `f.foo = 2` produces
245
+ `f['foo'] == [1, 2]`; each assignment appends. Reading `f.foo` returns
246
+ the first element; `f['foo']` returns the full array. This accumulative
247
+ semantics differs from [SQL](https://www.iso.org/standard/76583.html)
248
+ (one value per column) and most NoSQL stores where assignment overwrites.
249
+ New programmers must expect multi-element arrays on every property read.
250
+
251
+ Queries use a custom Lisp-style
252
+ [S-expression](https://en.wikipedia.org/wiki/S-expression) language:
253
+ `(and (eq kind 'book') (gt age 10))`. `Factbase::Syntax` tokenizes and
254
+ parses a query string into an AST of `Factbase::Term` objects;
255
+ `Factbase::Query#each` evaluates that AST against every fact. This
256
+ differs from [SQL](https://www.iso.org/standard/76583.html),
257
+ [XPath](https://www.w3.org/TR/xpath-31/), and
258
+ [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535). New
259
+ programmers add operators by implementing a term class, not by modifying
260
+ parser grammar.
261
+
262
+ Each query operator (`eq`, `gt`, `agg`, `join`, etc.) is a separate class
263
+ under `lib/factbase/terms/`. `Factbase::Term` holds a dispatch hash
264
+ (`@terms`) mapping operator symbols to instances and delegates `evaluate`
265
+ and `predict` calls there. This is not a class hierarchy — adding a new
266
+ operator requires a new file in `terms/` and a registration line in the
267
+ `Factbase::Term` constructor. New programmers extending the query
268
+ language must follow this two-step pattern.
269
+
270
+ Transactions are ACID and implemented via lazy copy-on-write journaling.
271
+ `Factbase#txn` wraps the array in `Factbase::LazyTaped`, which defers
272
+ physical duplication of hash objects until the first write. Inserts,
273
+ deletes, and property additions are tracked by Ruby `object_id`. On
274
+ commit the journal is replayed into the main array; raising
275
+ `Factbase::Rollback` discards it. Nesting transactions is explicitly
276
+ forbidden by `Factbase::Light`. This differs from SQLite's
277
+ [WAL](https://sqlite.org/wal.html) and PostgreSQL's
278
+ [MVCC](https://www.postgresql.org/docs/current/mvcc.html).
279
+
280
+ Cross-cutting capabilities — thread safety, indexing, constraint
281
+ validation, logging, and change counting — are added via decorators:
282
+ `Factbase::SyncFactbase`, `Factbase::IndexedFactbase`,
283
+ `Factbase::Rules`, `Factbase::Logged`, and `Factbase::Tallied`. The
284
+ [`decoor`](https://github.com/yegor256/decoor) gem provides delegation
285
+ boilerplate. The bare `Factbase` class is not thread-safe; new
286
+ programmers must wrap it with `SyncFactbase` before sharing across
287
+ threads.
288
+
289
+ Persistence uses Ruby's
290
+ [`Marshal`](https://ruby-doc.org/core/Marshal.html), serializing the
291
+ internal array of hashes to a binary blob via `Marshal.dump`. The format
292
+ is Ruby-version-specific and not portable across major Ruby versions or
293
+ platforms, unlike [JSON](https://www.json.org/json-en.html) or
294
+ [Protocol Buffers](https://protobuf.dev/). Output-only decorators
295
+ `Factbase::ToJSON`, `Factbase::ToXML`, and `Factbase::ToYAML` exist but
296
+ do not support round-trip import.
297
+
298
+ `Factbase::IndexedFactbase` lazily builds a hash-based inverted index for
299
+ equality queries, keyed by array `object_id`, property name, and
300
+ operator. The index is built incrementally on each query and invalidated
301
+ entirely on any mutation (delete or property addition). Without this
302
+ decorator every `query#each` call performs a full linear scan over all
303
+ facts. New programmers should add `IndexedFactbase` whenever the
304
+ factbase holds more than a few thousand facts.
305
+
230
306
  ## How to contribute
231
307
 
232
308
  Read
@@ -250,24 +326,24 @@ This is the result of the benchmark:
250
326
  <!-- benchmark_begin -->
251
327
  ```text
252
328
  user
253
- void scan 0.001102
254
- 20k facts: export: 2991KB 0.854774
255
- 20k facts: import: 2991KB 1.035671
256
- 50k facts: read 0.000138
257
- 50k facts: read in txn 0.002754
258
- 50k facts: insert 0.000090
259
- 50k facts: insert in txn 0.000243
260
- 50k facts: modify 1.085214
261
- 50k facts: modify in txn 2.480409
262
- 12k facts: large query: match 3k 14.187596
263
- 12k facts: large query: match 3k in txn 19.396334
264
- 12k facts: large query: match zero 15.139695
265
- 12k facts: large query: match zero in txn 21.046074
329
+ void scan 0.001049
330
+ 20k facts: export: 2980KB 0.840193
331
+ 20k facts: import: 2980KB 1.033492
332
+ 50k facts: read 0.000179
333
+ 50k facts: read in txn 0.002282
334
+ 50k facts: insert 0.000082
335
+ 50k facts: insert in txn 0.000613
336
+ 50k facts: modify 1.427349
337
+ 50k facts: modify in txn 2.773065
338
+ 12k facts: large query: match 3k 13.166170
339
+ 12k facts: large query: match 3k in txn 18.502015
340
+ 12k facts: large query: match zero 14.234812
341
+ 12k facts: large query: match zero in txn 19.847547
266
342
  ```
267
343
 
268
344
  The results were calculated in [this GHA job][benchmark-gha]
269
- on 2026-04-16 at 17:08,
345
+ on 2026-05-23 at 03:40,
270
346
  on Linux with 4 CPUs.
271
347
  <!-- benchmark_end -->
272
348
 
273
- [benchmark-gha]: https://github.com/yegor256/factbase/actions/runs/24523476155
349
+ [benchmark-gha]: https://github.com/yegor256/factbase/actions/runs/26322478578
@@ -142,7 +142,7 @@ class Factbase::Syntax
142
142
  t[1..-2]
143
143
  elsif t.match?(/^(\+|-)?[0-9]+$/)
144
144
  t.to_i
145
- elsif t.match?(/^(\+|-)?[0-9]+\.[0-9]+(e\+[0-9]+)?$/)
145
+ elsif t.match?(/^(\+|-)?[0-9]+\.[0-9]+(e(\+|-)[0-9]+)?$/)
146
146
  t.to_f
147
147
  elsif t.match?(/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/)
148
148
  Time.parse(t)
@@ -8,7 +8,7 @@ require 'others'
8
8
  require_relative '../factbase'
9
9
  require_relative 'churn'
10
10
 
11
- # A decorator of a Factbase, that count all operations and then returns
11
+ # A decorator of a Factbase, that counts all operations and then returns
12
12
  # an instance of Factbase::Churn.
13
13
  #
14
14
  # Author:: Yegor Bugayenko (yegor256@gmail.com)
data/lib/factbase/term.rb CHANGED
@@ -12,6 +12,9 @@ require_relative 'terms/prev'
12
12
  require_relative 'terms/concat'
13
13
  require_relative 'terms/sprintf'
14
14
  require_relative 'terms/matches'
15
+ require_relative 'terms/contains'
16
+ require_relative 'terms/starts_with'
17
+ require_relative 'terms/ends_with'
15
18
  require_relative 'terms/traced'
16
19
  require_relative 'terms/assert'
17
20
  require_relative 'terms/env'
@@ -108,6 +111,9 @@ class Factbase::Term < Factbase::TermBase
108
111
  concat: Factbase::Concat.new(operands),
109
112
  sprintf: Factbase::Sprintf.new(operands),
110
113
  matches: Factbase::Matches.new(operands),
114
+ contains: Factbase::Contains.new(operands),
115
+ starts_with: Factbase::StartsWith.new(operands),
116
+ ends_with: Factbase::EndsWith.new(operands),
111
117
  traced: Factbase::Traced.new(operands),
112
118
  assert: Factbase::Assert.new(operands),
113
119
  env: Factbase::Env.new(operands),
@@ -3,12 +3,10 @@
3
3
  # SPDX-FileCopyrightText: Copyright (c) 2024-2026 Yegor Bugayenko
4
4
  # SPDX-License-Identifier: MIT
5
5
 
6
- # Test for unique term.
6
+ # Base class for all terms.
7
7
  # Author:: Volodya Lombrozo (volodya.lombrozo@gmail.com)
8
8
  # Copyright:: Copyright (c) 2024-2026 Yegor Bugayenko
9
9
  # License:: MIT
10
-
11
- # Base class for all terms.
12
10
  class Factbase::TermBase
13
11
  # Turns it into a string.
14
12
  # @return [String] The string of it
@@ -31,8 +31,23 @@ class Factbase::Compare < Factbase::TermBase
31
31
  l = l.floor if l.is_a?(Time) && @op == :==
32
32
  rights.any? do |r|
33
33
  r = r.floor if r.is_a?(Time) && @op == :==
34
- l.send(@op, r)
34
+ _compare(l, r)
35
35
  end
36
36
  end
37
37
  end
38
+
39
+ private
40
+
41
+ # Compare values with a contextual error if Ruby rejects the operands.
42
+ # @param [Object] left Left value
43
+ # @param [Object] right Right value
44
+ # @return [Boolean] The result of the comparison
45
+ def _compare(left, right)
46
+ left.send(@op, right)
47
+ rescue ArgumentError => e
48
+ raise(
49
+ "Cannot compare #{left.inspect} (#{left.class}) " \
50
+ "with #{right.inspect} (#{right.class}) using (compare #{@op}): #{e.message}"
51
+ )
52
+ end
38
53
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2026 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative 'base'
7
+ require_relative 'compare'
8
+
9
+ # Represents a 'contains' term in the Factbase.
10
+ # Returns true if any value of the left operand contains any value of the right
11
+ # as a substring. Operates on string values via `String#include?`.
12
+ class Factbase::Contains < Factbase::TermBase
13
+ # Constructor.
14
+ # @param [Array] operands Operands
15
+ def initialize(operands)
16
+ super()
17
+ @op = Factbase::Compare.new(:include?, operands)
18
+ end
19
+
20
+ # Evaluate term on a fact.
21
+ # @param [Factbase::Fact] fact The fact
22
+ # @param [Array<Factbase::Fact>] maps All maps available
23
+ # @param [Factbase] fb Factbase to use for sub-queries
24
+ # @return [Boolean] True if any left value includes any right value
25
+ def evaluate(fact, maps, fb)
26
+ @op.evaluate(fact, maps, fb)
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2026 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative 'base'
7
+ require_relative 'compare'
8
+
9
+ # Represents an 'ends_with' term in the Factbase.
10
+ # Returns true if any value of the left operand ends with any value of the right.
11
+ class Factbase::EndsWith < Factbase::TermBase
12
+ # Constructor.
13
+ # @param [Array] operands Operands
14
+ def initialize(operands)
15
+ super()
16
+ @op = Factbase::Compare.new(:end_with?, operands)
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 [Boolean] True if any left value ends with any right value
24
+ def evaluate(fact, maps, fb)
25
+ @op.evaluate(fact, maps, fb)
26
+ end
27
+ end
@@ -14,6 +14,7 @@ class Factbase::Matches < Factbase::TermBase
14
14
  def initialize(operands)
15
15
  super()
16
16
  @operands = operands
17
+ @regexps = {}
17
18
  end
18
19
 
19
20
  # Evaluate term on a fact.
@@ -29,6 +30,15 @@ class Factbase::Matches < Factbase::TermBase
29
30
  re = _values(1, fact, maps, fb)
30
31
  raise 'Regexp is nil' if re.nil?
31
32
  raise 'Exactly one regexp is expected' unless re.size == 1
32
- str[0].to_s.match?(re[0])
33
+ str[0].to_s.match?(regexp(re[0]))
34
+ end
35
+
36
+ private
37
+
38
+ def regexp(pattern)
39
+ key = pattern.to_s
40
+ @regexps[key] ||= Regexp.new(key)
41
+ rescue RegexpError => e
42
+ raise "Invalid regexp '#{key}': #{e.message}"
33
43
  end
34
44
  end
@@ -24,6 +24,14 @@ class Factbase::Sprintf < Factbase::TermBase
24
24
  def evaluate(fact, maps, fb)
25
25
  fmt = _values(0, fact, maps, fb)[0]
26
26
  ops = (1..(@operands.length - 1)).map { |i| _values(i, fact, maps, fb)&.first }
27
+ formatted(fmt, ops)
28
+ end
29
+
30
+ private
31
+
32
+ def formatted(fmt, ops)
27
33
  format(*([fmt] + ops))
34
+ rescue ArgumentError => e
35
+ raise "Cannot format #{ops.inspect} with '#{fmt}' in (sprintf ...): #{e.message}"
28
36
  end
29
37
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: Copyright (c) 2024-2026 Yegor Bugayenko
4
+ # SPDX-License-Identifier: MIT
5
+
6
+ require_relative 'base'
7
+ require_relative 'compare'
8
+
9
+ # Represents a 'starts_with' term in the Factbase.
10
+ # Returns true if any value of the left operand starts with any value of the right.
11
+ class Factbase::StartsWith < Factbase::TermBase
12
+ # Constructor.
13
+ # @param [Array] operands Operands
14
+ def initialize(operands)
15
+ super()
16
+ @op = Factbase::Compare.new(:start_with?, operands)
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 [Boolean] True if any left value starts with any right value
24
+ def evaluate(fact, maps, fb)
25
+ @op.evaluate(fact, maps, fb)
26
+ end
27
+ end
@@ -6,7 +6,7 @@
6
6
  require_relative 'base'
7
7
 
8
8
  # This class represents a specialized 'sum' term.
9
- # This term calculates the sum of values for a specified key.
9
+ # This term calculates the sum of values for a specified key.
10
10
  class Factbase::Sum < Factbase::TermBase
11
11
  # Constructor.
12
12
  # @param [Array] operands Operands
@@ -23,6 +23,14 @@ class Factbase::ToTime < Factbase::TermBase
23
23
  assert_args(1)
24
24
  vv = _values(0, fact, maps, fb)
25
25
  return nil if vv.nil?
26
- Time.parse(vv[0].to_s)
26
+ parse(vv[0])
27
+ end
28
+
29
+ private
30
+
31
+ def parse(value)
32
+ Time.parse(value.to_s)
33
+ rescue ArgumentError => e
34
+ raise "Cannot parse '#{value}' as Time in (to_time ...): #{e.message}"
27
35
  end
28
36
  end
@@ -22,8 +22,7 @@ class Factbase::When < Factbase::TermBase
22
22
  # @return [Boolean] True if first operand is false OR both are true
23
23
  def evaluate(fact, maps, fb)
24
24
  assert_args(2)
25
- a = @operands[0]
26
- b = @operands[1]
27
- !a.evaluate(fact, maps, fb) || (a.evaluate(fact, maps, fb) && b.evaluate(fact, maps, fb))
25
+ return true unless @operands[0].evaluate(fact, maps, fb)
26
+ @operands[1].evaluate(fact, maps, fb)
28
27
  end
29
28
  end
@@ -9,7 +9,7 @@ require_relative '../factbase/flatten'
9
9
 
10
10
  # Factbase to JSON converter.
11
11
  #
12
- # This class helps converting an entire Factbase to YAML format, for example:
12
+ # This class helps converting an entire Factbase to JSON format, for example:
13
13
  #
14
14
  # require 'factbase/to_json'
15
15
  # fb = Factbase.new
@@ -10,7 +10,7 @@ require_relative '../factbase/flatten'
10
10
 
11
11
  # Factbase to XML converter.
12
12
  #
13
- # This class helps converting an entire Factbase to YAML format, for example:
13
+ # This class helps converting an entire Factbase to XML format, for example:
14
14
  #
15
15
  # require 'factbase/to_xml'
16
16
  # fb = Factbase.new
@@ -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.19.10' unless const_defined?(:VERSION)
12
+ VERSION = '0.19.11' unless const_defined?(:VERSION)
13
13
  end
data/lib/factbase.rb CHANGED
@@ -102,9 +102,6 @@ class Factbase
102
102
  #
103
103
  # A fact, when inserted, is empty. It doesn't contain any properties.
104
104
  #
105
- # The operation is thread-safe, meaning that different threads may
106
- # insert facts in parallel without breaking the consistency of the factbase.
107
- #
108
105
  # @return [Factbase::Fact] The fact just inserted
109
106
  def insert
110
107
  map = {}
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.19.10
4
+ version: 0.19.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yegor Bugayenko
@@ -222,11 +222,13 @@ files:
222
222
  - lib/factbase/terms/boolean.rb
223
223
  - lib/factbase/terms/compare.rb
224
224
  - lib/factbase/terms/concat.rb
225
+ - lib/factbase/terms/contains.rb
225
226
  - lib/factbase/terms/count.rb
226
227
  - lib/factbase/terms/defn.rb
227
228
  - lib/factbase/terms/div.rb
228
229
  - lib/factbase/terms/either.rb
229
230
  - lib/factbase/terms/empty.rb
231
+ - lib/factbase/terms/ends_with.rb
230
232
  - lib/factbase/terms/env.rb
231
233
  - lib/factbase/terms/eq.rb
232
234
  - lib/factbase/terms/exists.rb
@@ -255,6 +257,7 @@ files:
255
257
  - lib/factbase/terms/size.rb
256
258
  - lib/factbase/terms/sorted.rb
257
259
  - lib/factbase/terms/sprintf.rb
260
+ - lib/factbase/terms/starts_with.rb
258
261
  - lib/factbase/terms/sum.rb
259
262
  - lib/factbase/terms/times.rb
260
263
  - lib/factbase/terms/to_float.rb