nyxis 1.0.0
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 +7 -0
- data/LICENSE +53 -0
- data/README.md +134 -0
- data/nxs.rb +511 -0
- metadata +51 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0ebcbfcd1dfc34288e738e81acc006a8cabda0fdc50e9e851c73e55d422dadb2
|
|
4
|
+
data.tar.gz: d1eb4b53469a62e382bbf385cf039307af2a5be59b62ba0db0d6f0abe3dffe73
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 74dcb43ae96b59f6a56b52dd64d03efb2074c8f3789a99fbe1889b26ed931cb9280fd418036d837702a28cf040d79722f7d51a120d43788aa239f1ec6dbb4d35
|
|
7
|
+
data.tar.gz: 339bf6357eebf0557ea3d114dbe970eb42f9f796f6665db9ca6369a59c2c63237135b2ff4681b5305bea05a15f1fc6b09c8347e8a836244ae53afd9f354966c6
|
data/LICENSE
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
Licensor: Nyxis Authors
|
|
4
|
+
|
|
5
|
+
Licensed Work: Nyxis, including all source code, documentation, examples,
|
|
6
|
+
conformance vectors, tools, packages, and other files in this repository,
|
|
7
|
+
unless a file states a different license.
|
|
8
|
+
|
|
9
|
+
Additional Use Grant:
|
|
10
|
+
|
|
11
|
+
You may use the Licensed Work in production free of charge only if the legal
|
|
12
|
+
entity using the Licensed Work meets at least one of these conditions:
|
|
13
|
+
|
|
14
|
+
1. The entity generates less than US$1,000,000 in annual gross revenue; or
|
|
15
|
+
2. The entity processes less than 100 GB of data per calendar month using the
|
|
16
|
+
Licensed Work.
|
|
17
|
+
|
|
18
|
+
If neither condition is true, production use is not granted under this license.
|
|
19
|
+
Any production use by that entity is a license violation unless the entity has
|
|
20
|
+
a separate commercial license from the Licensor.
|
|
21
|
+
|
|
22
|
+
Non-production use, including development, testing, evaluation, benchmarking,
|
|
23
|
+
research, and personal use, is permitted under the Business Source License 1.1.
|
|
24
|
+
|
|
25
|
+
Change Date: 2030-05-20
|
|
26
|
+
|
|
27
|
+
Change License: MIT License
|
|
28
|
+
|
|
29
|
+
For information about alternative licensing or commercial terms, contact the
|
|
30
|
+
Licensor.
|
|
31
|
+
|
|
32
|
+
Terms
|
|
33
|
+
|
|
34
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
35
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
36
|
+
Licensor may make an Additional Use Grant, above, permitting limited production
|
|
37
|
+
use.
|
|
38
|
+
|
|
39
|
+
Effective on the Change Date, or the fourth anniversary of the first publicly
|
|
40
|
+
available distribution of a specific version of the Licensed Work under this
|
|
41
|
+
License, whichever comes first, the Licensed Work will be licensed under the
|
|
42
|
+
Change License, and the rights granted under this License will terminate.
|
|
43
|
+
|
|
44
|
+
If your use of the Licensed Work does not comply with this License, you must
|
|
45
|
+
cease use immediately. Your rights under this License terminate automatically if
|
|
46
|
+
you violate this License.
|
|
47
|
+
|
|
48
|
+
The Licensed Work is provided "as is", without warranty of any kind, express or
|
|
49
|
+
implied, including but not limited to the warranties of merchantability, fitness
|
|
50
|
+
for a particular purpose, and noninfringement. In no event will the Licensor be
|
|
51
|
+
liable for any claim, damages, or other liability, whether in an action of
|
|
52
|
+
contract, tort, or otherwise, arising from, out of, or in connection with the
|
|
53
|
+
Licensed Work or the use or other dealings in the Licensed Work.
|
data/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# NXS — Ruby
|
|
2
|
+
|
|
3
|
+
Zero-copy `.nxb` reader for Ruby 3.x. Pure-Ruby implementation with an optional C extension for hot-path columnar scans. No gems required.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
Ruby 3.0+. The C extension requires a C compiler and Ruby headers (`ruby-dev` / `ruby-devel`).
|
|
8
|
+
|
|
9
|
+
## Read a file
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require_relative "nxs"
|
|
13
|
+
|
|
14
|
+
bytes = File.binread("data.nxb")
|
|
15
|
+
reader = Nxs::Reader.new(bytes)
|
|
16
|
+
|
|
17
|
+
puts reader.record_count # instant — read from tail-index, no parse pass
|
|
18
|
+
obj = reader.record(42) # O(1) seek
|
|
19
|
+
puts obj.get_str("username")
|
|
20
|
+
puts obj.get_f64("score")
|
|
21
|
+
puts obj.get_bool("active")
|
|
22
|
+
puts obj.get_i64("id")
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Columnar reducers
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
total = reader.sum_f64("score")
|
|
29
|
+
low = reader.min_f64("score")
|
|
30
|
+
high = reader.max_f64("score")
|
|
31
|
+
ages = reader.sum_i64("age")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## C extension (hot path)
|
|
35
|
+
|
|
36
|
+
Build once:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bash ext/build.sh
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require_relative "ext/nxs/nxs_ext" # loads Nxs::CReader and Nxs::CObject
|
|
44
|
+
|
|
45
|
+
reader = Nxs::CReader.new(bytes)
|
|
46
|
+
puts reader.record(42).get_str("username")
|
|
47
|
+
puts reader.sum_f64("score") # 6.78 ms at 1M records vs 942 ms pure Ruby
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
At 1M records the C extension is **139× faster** than pure Ruby for `sum_f64`, and **5.6× faster** than `JSON.parse`.
|
|
51
|
+
|
|
52
|
+
## Write a file
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
require_relative "nxs_writer"
|
|
56
|
+
|
|
57
|
+
schema = Nxs::Schema.new(["id", "username", "score", "active"])
|
|
58
|
+
w = Nxs::Writer.new(schema)
|
|
59
|
+
|
|
60
|
+
w.begin_object
|
|
61
|
+
w.write_i64(0, 42)
|
|
62
|
+
w.write_str(1, "alice")
|
|
63
|
+
w.write_f64(2, 9.5)
|
|
64
|
+
w.write_bool(3, true)
|
|
65
|
+
w.end_object
|
|
66
|
+
|
|
67
|
+
data = w.finish # binary String (encoding ASCII-8BIT)
|
|
68
|
+
|
|
69
|
+
# Convenience: write from an array of hashes
|
|
70
|
+
data2 = Nxs::Writer.from_records(
|
|
71
|
+
["id", "username", "score"],
|
|
72
|
+
[{ "id" => 1, "username" => "bob", "score" => 8.2 }]
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Tests
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ruby test.rb ../js/fixtures # 22 tests
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Benchmarks
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
ruby bench.rb ../js/fixtures # pure Ruby vs JSON
|
|
86
|
+
ruby bench_c.rb ../js/fixtures # C extension vs JSON
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Files
|
|
90
|
+
|
|
91
|
+
| File | Purpose |
|
|
92
|
+
| :--- | :--- |
|
|
93
|
+
| `nxs.rb` | Pure-Ruby reader (`Nxs::Reader`, `Nxs::Object`) |
|
|
94
|
+
| `nxs_writer.rb` | Pure-Ruby writer (`Nxs::Schema`, `Nxs::Writer`) |
|
|
95
|
+
| `ext/nxs/nxs_ext.c` | C extension source (`Nxs::CReader`, `Nxs::CObject`) |
|
|
96
|
+
| `ext/nxs/extconf.rb` | Extension build configuration |
|
|
97
|
+
| `ext/build.sh` | Compiles the C extension |
|
|
98
|
+
|
|
99
|
+
## Query engine
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
require_relative 'nxs'
|
|
103
|
+
|
|
104
|
+
data = File.binread("data.nxb")
|
|
105
|
+
reader = Nxs::Reader.new(data)
|
|
106
|
+
|
|
107
|
+
# Count matching records
|
|
108
|
+
n = reader.where(Nxs::Eq.new("active", true) & Nxs::Gt.new("score", 80.0)).count
|
|
109
|
+
|
|
110
|
+
# Iterate — yields Nxs::Object
|
|
111
|
+
reader.where(Nxs::Eq.new("active", true)).each do |obj|
|
|
112
|
+
puts obj.get_str("username")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# First match or nil
|
|
116
|
+
first = reader.where(Nxs::Gt.new("score", 99.0)).first
|
|
117
|
+
|
|
118
|
+
# All records
|
|
119
|
+
reader.all.each { |obj| ... }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Predicates
|
|
123
|
+
|
|
124
|
+
| Class | Matches |
|
|
125
|
+
|-------|---------|
|
|
126
|
+
| `Eq.new(key, value)` | equality — String, Integer, Float, boolean |
|
|
127
|
+
| `Gt.new(key, v)` / `Lt.new(key, v)` | numeric comparison |
|
|
128
|
+
| `p1 & p2` / `p1 \| p2` / `~p` | And / Or / Not via operator overloads |
|
|
129
|
+
|
|
130
|
+
`Query` includes `Enumerable` — all `map`, `select`, `reject` etc. are available.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
For the format specification see [`SPEC.md`](../SPEC.md). For cross-language examples see [`GETTING_STARTED.md`](../GETTING_STARTED.md).
|
data/nxs.rb
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# NXS Reader — .nxb parser (Ruby 3.x, stdlib only).
|
|
4
|
+
#
|
|
5
|
+
# Implements Nyxis v1.1 binary wire format.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# buf = File.binread("data.nxb")
|
|
9
|
+
# reader = Nxs::Reader.new(buf)
|
|
10
|
+
# reader.record_count # => Integer
|
|
11
|
+
# reader.keys # => Array<String>
|
|
12
|
+
# obj = reader.record(42) # => Nxs::Object
|
|
13
|
+
# obj.get_str("username") # => String | nil
|
|
14
|
+
# obj.get_i64("id") # => Integer | nil
|
|
15
|
+
# obj.get_f64("score") # => Float | nil
|
|
16
|
+
# obj.get_bool("active") # => true/false | nil
|
|
17
|
+
# reader.sum_f64("score") # => Float
|
|
18
|
+
# reader.min_f64("score") # => Float | nil
|
|
19
|
+
# reader.max_f64("score") # => Float | nil
|
|
20
|
+
# reader.sum_i64("id") # => Integer
|
|
21
|
+
|
|
22
|
+
module Nxs
|
|
23
|
+
MAGIC_FILE = 0x4E595842 # NYXB
|
|
24
|
+
MAGIC_OBJ = 0x4E59584F # NYXO
|
|
25
|
+
MAGIC_FOOTER = 0x2153584E # NXS!
|
|
26
|
+
FLAG_SCHEMA = 0x0002
|
|
27
|
+
|
|
28
|
+
class NxsError < StandardError
|
|
29
|
+
attr_reader :code
|
|
30
|
+
|
|
31
|
+
def initialize(code, msg)
|
|
32
|
+
super("#{code}: #{msg}")
|
|
33
|
+
@code = code
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# ── Reader ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
class Reader
|
|
40
|
+
attr_reader :keys, :record_count
|
|
41
|
+
|
|
42
|
+
def initialize(bytes)
|
|
43
|
+
@data = bytes.b # force binary encoding
|
|
44
|
+
sz = @data.bytesize
|
|
45
|
+
raise NxsError.new('ERR_OUT_OF_BOUNDS', 'file too small') if sz < 32
|
|
46
|
+
|
|
47
|
+
magic = @data.unpack1('L<')
|
|
48
|
+
raise NxsError.new('ERR_BAD_MAGIC', "expected NYXB, got 0x#{magic.to_s(16)}") if magic != MAGIC_FILE
|
|
49
|
+
|
|
50
|
+
footer = @data.unpack1("@#{sz - 4}L<")
|
|
51
|
+
raise NxsError.new('ERR_BAD_MAGIC', 'footer magic mismatch') if footer != MAGIC_FOOTER
|
|
52
|
+
|
|
53
|
+
# Preamble: Version(2) + Flags(2) + DictHash(8) + TailPtr(8) + Reserved(8)
|
|
54
|
+
@flags = @data.unpack1('@6 S<')
|
|
55
|
+
@tail_ptr = @data.unpack1('@16 Q<')
|
|
56
|
+
if @tail_ptr.zero?
|
|
57
|
+
raise NxsError.new('ERR_OUT_OF_BOUNDS', 'stream footer') if sz < 44
|
|
58
|
+
|
|
59
|
+
@tail_ptr = @data.unpack1("@#{sz - 12}Q<")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@dict_hash = @data.unpack1('@8 Q<')
|
|
63
|
+
|
|
64
|
+
# Schema (when Flags bit 1 set)
|
|
65
|
+
@keys = []
|
|
66
|
+
@key_sigils = []
|
|
67
|
+
@key_index = {}
|
|
68
|
+
if @flags & FLAG_SCHEMA != 0
|
|
69
|
+
schema_end = read_schema(32)
|
|
70
|
+
computed = murmur3_64(@data[32...schema_end].bytes)
|
|
71
|
+
raise NxsError.new('ERR_DICT_MISMATCH', 'schema hash mismatch') if computed != @dict_hash
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Tail-index: u32 EntryCount followed by records
|
|
75
|
+
@record_count = @data.unpack1("@#{@tail_ptr}L<")
|
|
76
|
+
@tail_start = @tail_ptr + 4
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# O(1) record lookup — reads one 10-byte tail-index entry.
|
|
80
|
+
def record(i)
|
|
81
|
+
unless i >= 0 && i < @record_count
|
|
82
|
+
raise NxsError.new('ERR_OUT_OF_BOUNDS', "record #{i} out of [0, #{@record_count})")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Each tail-index entry: u16 KeyID + u64 AbsoluteOffset = 10 bytes
|
|
86
|
+
abs_offset = @data.unpack1("@#{@tail_start + i * 10 + 2}Q<")
|
|
87
|
+
Object.new(self, abs_offset)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Tight allocation-free sum loop.
|
|
91
|
+
def sum_f64(key)
|
|
92
|
+
slot = @key_index[key]
|
|
93
|
+
raise NxsError.new('ERR_OUT_OF_BOUNDS', "key '#{key}' not in schema") unless slot
|
|
94
|
+
|
|
95
|
+
data = @data
|
|
96
|
+
tail = @tail_start
|
|
97
|
+
n = @record_count
|
|
98
|
+
sum = 0.0
|
|
99
|
+
i = 0
|
|
100
|
+
while i < n
|
|
101
|
+
abs = data.unpack1("@#{tail + i * 10 + 2}Q<")
|
|
102
|
+
off = _scan_offset(data, abs, slot)
|
|
103
|
+
sum += data.unpack1("@#{off}E") if off
|
|
104
|
+
i += 1
|
|
105
|
+
end
|
|
106
|
+
sum
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def min_f64(key)
|
|
110
|
+
slot = @key_index[key]
|
|
111
|
+
raise NxsError.new('ERR_OUT_OF_BOUNDS', "key '#{key}' not in schema") unless slot
|
|
112
|
+
|
|
113
|
+
data = @data
|
|
114
|
+
tail = @tail_start
|
|
115
|
+
n = @record_count
|
|
116
|
+
min = nil
|
|
117
|
+
i = 0
|
|
118
|
+
while i < n
|
|
119
|
+
abs = data.unpack1("@#{tail + i * 10 + 2}Q<")
|
|
120
|
+
off = _scan_offset(data, abs, slot)
|
|
121
|
+
if off
|
|
122
|
+
v = data.unpack1("@#{off}E")
|
|
123
|
+
min = v if min.nil? || v < min
|
|
124
|
+
end
|
|
125
|
+
i += 1
|
|
126
|
+
end
|
|
127
|
+
min
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def max_f64(key)
|
|
131
|
+
slot = @key_index[key]
|
|
132
|
+
raise NxsError.new('ERR_OUT_OF_BOUNDS', "key '#{key}' not in schema") unless slot
|
|
133
|
+
|
|
134
|
+
data = @data
|
|
135
|
+
tail = @tail_start
|
|
136
|
+
n = @record_count
|
|
137
|
+
max = nil
|
|
138
|
+
i = 0
|
|
139
|
+
while i < n
|
|
140
|
+
abs = data.unpack1("@#{tail + i * 10 + 2}Q<")
|
|
141
|
+
off = _scan_offset(data, abs, slot)
|
|
142
|
+
if off
|
|
143
|
+
v = data.unpack1("@#{off}E")
|
|
144
|
+
max = v if max.nil? || v > max
|
|
145
|
+
end
|
|
146
|
+
i += 1
|
|
147
|
+
end
|
|
148
|
+
max
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def sum_i64(key)
|
|
152
|
+
slot = @key_index[key]
|
|
153
|
+
raise NxsError.new('ERR_OUT_OF_BOUNDS', "key '#{key}' not in schema") unless slot
|
|
154
|
+
|
|
155
|
+
data = @data
|
|
156
|
+
tail = @tail_start
|
|
157
|
+
n = @record_count
|
|
158
|
+
sum = 0
|
|
159
|
+
i = 0
|
|
160
|
+
while i < n
|
|
161
|
+
abs = data.unpack1("@#{tail + i * 10 + 2}Q<")
|
|
162
|
+
off = _scan_offset(data, abs, slot)
|
|
163
|
+
sum += data.unpack1("@#{off}q<") if off
|
|
164
|
+
i += 1
|
|
165
|
+
end
|
|
166
|
+
sum
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Expose internals for Object
|
|
170
|
+
attr_reader :data, :key_index
|
|
171
|
+
|
|
172
|
+
# Walk the LEB128 bitmask from obj_offset+8, count set bits before `slot`,
|
|
173
|
+
# and return the absolute byte offset of the field value (or nil if absent).
|
|
174
|
+
# Used by both bulk reducers and NxsObject.
|
|
175
|
+
def _scan_offset(data, obj_offset, slot)
|
|
176
|
+
p = obj_offset + 8 # skip Magic(4) + Length(4)
|
|
177
|
+
cur = 0
|
|
178
|
+
t_idx = 0
|
|
179
|
+
|
|
180
|
+
loop do
|
|
181
|
+
b = data.getbyte(p)
|
|
182
|
+
p += 1
|
|
183
|
+
bits = b & 0x7F
|
|
184
|
+
7.times do |i|
|
|
185
|
+
if cur == slot
|
|
186
|
+
# field absent if bit is 0
|
|
187
|
+
return nil if ((bits >> i) & 1).zero?
|
|
188
|
+
|
|
189
|
+
# p already past this bitmask byte; drain remaining continuation bytes
|
|
190
|
+
while (b & 0x80) != 0
|
|
191
|
+
b = data.getbyte(p)
|
|
192
|
+
p += 1
|
|
193
|
+
end
|
|
194
|
+
# p now points to the offset table
|
|
195
|
+
rel = data.unpack1("@#{p + t_idx * 2}S<")
|
|
196
|
+
return obj_offset + rel
|
|
197
|
+
end
|
|
198
|
+
t_idx += 1 if (bits >> i) & 1 == 1
|
|
199
|
+
cur += 1
|
|
200
|
+
end
|
|
201
|
+
# If all 7 bits processed and continuation bit clear, field is absent
|
|
202
|
+
return nil if (b & 0x80).zero?
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def read_schema(offset)
|
|
209
|
+
key_count = @data.unpack1("@#{offset}S<")
|
|
210
|
+
offset += 2
|
|
211
|
+
|
|
212
|
+
@key_sigils = @data[offset, key_count].bytes
|
|
213
|
+
offset += key_count
|
|
214
|
+
|
|
215
|
+
# Null-terminated UTF-8 strings in StringPool
|
|
216
|
+
pool = @data[offset..]
|
|
217
|
+
pos = 0
|
|
218
|
+
key_count.times do |i|
|
|
219
|
+
term = pool.index("\x00", pos)
|
|
220
|
+
@keys << pool[pos...term].force_encoding('UTF-8')
|
|
221
|
+
@key_index[@keys.last] = i
|
|
222
|
+
pos = term + 1
|
|
223
|
+
end
|
|
224
|
+
offset += pos
|
|
225
|
+
|
|
226
|
+
# Pad to 8-byte boundary
|
|
227
|
+
rem = offset % 8
|
|
228
|
+
offset += (8 - rem) % 8
|
|
229
|
+
offset
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
MURMUR_C1 = 0xFF51AFD7ED558CCD
|
|
233
|
+
MURMUR_C2 = 0xC4CEB9FE1A85EC53
|
|
234
|
+
MURMUR_MASK = 0xFFFFFFFFFFFFFFFF
|
|
235
|
+
|
|
236
|
+
def murmur3_64(bytes)
|
|
237
|
+
h = 0x93681D6255313A99
|
|
238
|
+
i = 0
|
|
239
|
+
len = bytes.length
|
|
240
|
+
while i < len
|
|
241
|
+
chunk = bytes[i, 8]
|
|
242
|
+
k = 0
|
|
243
|
+
chunk.each_with_index { |b, j| k |= b << (j * 8) }
|
|
244
|
+
k = (k * MURMUR_C1) & MURMUR_MASK
|
|
245
|
+
k ^= k >> 33
|
|
246
|
+
h ^= k
|
|
247
|
+
h = (h * MURMUR_C2) & MURMUR_MASK
|
|
248
|
+
h ^= h >> 33
|
|
249
|
+
i += 8
|
|
250
|
+
end
|
|
251
|
+
h ^= len
|
|
252
|
+
h ^= h >> 33
|
|
253
|
+
h = (h * MURMUR_C1) & MURMUR_MASK
|
|
254
|
+
h ^= h >> 33
|
|
255
|
+
h
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ── Query engine ─────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
# Base predicate — supports & | ~ operator overloading.
|
|
262
|
+
class Predicate
|
|
263
|
+
def &(other) = And.new(self, other)
|
|
264
|
+
def |(other) = Or.new(self, other)
|
|
265
|
+
def ~@ = Not.new(self)
|
|
266
|
+
def call(_record) = raise NotImplementedError, "#{self.class}#call not implemented"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Eq(key, value) — equality for String, Integer, Float, or boolean.
|
|
270
|
+
class Eq < Predicate
|
|
271
|
+
def initialize(key, value)
|
|
272
|
+
super()
|
|
273
|
+
@key = key
|
|
274
|
+
@value = value
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def call(record) = record[@key] == @value
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Gt(key, number) — numeric greater-than.
|
|
281
|
+
class Gt < Predicate
|
|
282
|
+
def initialize(key, value)
|
|
283
|
+
super()
|
|
284
|
+
@key = key
|
|
285
|
+
@value = value
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def call(record)
|
|
289
|
+
v = record[@key]
|
|
290
|
+
v.is_a?(Numeric) && v > @value
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Lt(key, number) — numeric less-than.
|
|
295
|
+
class Lt < Predicate
|
|
296
|
+
def initialize(key, value)
|
|
297
|
+
super()
|
|
298
|
+
@key = key
|
|
299
|
+
@value = value
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def call(record)
|
|
303
|
+
v = record[@key]
|
|
304
|
+
v.is_a?(Numeric) && v < @value
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# And(p1, p2) — conjunction.
|
|
309
|
+
class And < Predicate
|
|
310
|
+
def initialize(a, b)
|
|
311
|
+
super()
|
|
312
|
+
@a = a
|
|
313
|
+
@b = b
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def call(record) = @a.call(record) && @b.call(record)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Or(p1, p2) — disjunction.
|
|
320
|
+
class Or < Predicate
|
|
321
|
+
def initialize(a, b)
|
|
322
|
+
super()
|
|
323
|
+
@a = a
|
|
324
|
+
@b = b
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def call(record) = @a.call(record) || @b.call(record)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Not(p) — negation.
|
|
331
|
+
class Not < Predicate
|
|
332
|
+
def initialize(inner)
|
|
333
|
+
super()
|
|
334
|
+
@inner = inner
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def call(record) = !@inner.call(record)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# ── Record proxy ─────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
# Thin hash-like wrapper around Nxs::Object so predicates can use record[key].
|
|
343
|
+
# Values are fetched lazily and memoised per field access.
|
|
344
|
+
class RecordProxy
|
|
345
|
+
def initialize(obj, reader)
|
|
346
|
+
@obj = obj
|
|
347
|
+
@reader = reader
|
|
348
|
+
@cache = {}
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Reset to a new underlying object, clearing the field cache.
|
|
352
|
+
# Used by Query#each to reuse a single RecordProxy instance across iterations.
|
|
353
|
+
def reset(obj)
|
|
354
|
+
@obj = obj
|
|
355
|
+
@cache = {}
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def [](key)
|
|
359
|
+
return @cache[key] if @cache.key?(key)
|
|
360
|
+
|
|
361
|
+
slot = @reader.key_index[key]
|
|
362
|
+
unless slot
|
|
363
|
+
@cache[key] = nil
|
|
364
|
+
return nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
sigil = @reader.key_sigils[slot]
|
|
368
|
+
val = case sigil
|
|
369
|
+
when 0x22 then @obj.get_str(key) # '"' string
|
|
370
|
+
when 0x3D then @obj.get_i64(key) # '=' i64
|
|
371
|
+
when 0x7E then @obj.get_f64(key) # '~' f64
|
|
372
|
+
when 0x3F then @obj.get_bool(key) # '?' bool
|
|
373
|
+
end
|
|
374
|
+
@cache[key] = val
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# ── Query ─────────────────────────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
# Lazy filtered view over a Reader. Created via reader.where(pred) or reader.all.
|
|
381
|
+
#
|
|
382
|
+
# Includes Enumerable, so map/select/min/max etc. all work automatically.
|
|
383
|
+
# count and first are overridden for clarity (Enumerable would work too).
|
|
384
|
+
class Query
|
|
385
|
+
include Enumerable
|
|
386
|
+
|
|
387
|
+
def initialize(reader, pred = nil)
|
|
388
|
+
@reader = reader
|
|
389
|
+
@pred = pred
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Yield each matching Nxs::Object to the block.
|
|
393
|
+
def each
|
|
394
|
+
n = @reader.record_count
|
|
395
|
+
pred = @pred
|
|
396
|
+
proxy = RecordProxy.new(nil, @reader)
|
|
397
|
+
i = 0
|
|
398
|
+
while i < n
|
|
399
|
+
obj = @reader.record(i)
|
|
400
|
+
if pred.nil?
|
|
401
|
+
yield obj
|
|
402
|
+
else
|
|
403
|
+
proxy.reset(obj)
|
|
404
|
+
yield obj if pred.call(proxy)
|
|
405
|
+
end
|
|
406
|
+
i += 1
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Number of matching records (no block form; delegates to Enumerable when block given).
|
|
411
|
+
def count(&blk)
|
|
412
|
+
return super if blk
|
|
413
|
+
|
|
414
|
+
n = 0
|
|
415
|
+
each { n += 1 }
|
|
416
|
+
n
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# First matching record, or nil.
|
|
420
|
+
def first
|
|
421
|
+
find { true }
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# ── Reader extensions ────────────────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
class Reader
|
|
428
|
+
# Returns a Query filtered by pred.
|
|
429
|
+
def where(pred) = Query.new(self, pred)
|
|
430
|
+
|
|
431
|
+
# Returns a Query over all records.
|
|
432
|
+
def all = Query.new(self)
|
|
433
|
+
|
|
434
|
+
# Expose key_sigils for RecordProxy
|
|
435
|
+
attr_reader :key_sigils
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# ── Object ───────────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
class Object
|
|
441
|
+
def initialize(reader, offset)
|
|
442
|
+
@reader = reader
|
|
443
|
+
@offset = offset
|
|
444
|
+
@parsed = false
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def get_str(key)
|
|
448
|
+
off = field_offset(key)
|
|
449
|
+
return nil unless off
|
|
450
|
+
|
|
451
|
+
len = @reader.data.unpack1("@#{off}L<")
|
|
452
|
+
@reader.data[off + 4, len].force_encoding('UTF-8')
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def get_i64(key)
|
|
456
|
+
off = field_offset(key)
|
|
457
|
+
return nil unless off
|
|
458
|
+
|
|
459
|
+
@reader.data.unpack1("@#{off}q<")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def get_f64(key)
|
|
463
|
+
off = field_offset(key)
|
|
464
|
+
return nil unless off
|
|
465
|
+
|
|
466
|
+
@reader.data.unpack1("@#{off}E")
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def get_bool(key)
|
|
470
|
+
off = field_offset(key)
|
|
471
|
+
return nil unless off
|
|
472
|
+
|
|
473
|
+
@reader.data.getbyte(off) != 0
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
private
|
|
477
|
+
|
|
478
|
+
# Parse the object header (lazy — only on first field access).
|
|
479
|
+
def parse_header
|
|
480
|
+
return if @parsed
|
|
481
|
+
|
|
482
|
+
p = @offset
|
|
483
|
+
|
|
484
|
+
magic = @reader.data.unpack1("@#{p}L<")
|
|
485
|
+
raise NxsError.new('ERR_BAD_MAGIC', "expected NYXO at #{p}") if magic != MAGIC_OBJ
|
|
486
|
+
|
|
487
|
+
p += 8 # skip Magic(4) + Length(4)
|
|
488
|
+
|
|
489
|
+
bitmask = []
|
|
490
|
+
loop do
|
|
491
|
+
b = @reader.data.getbyte(p)
|
|
492
|
+
p += 1
|
|
493
|
+
bitmask << (b & 0x7F)
|
|
494
|
+
break if (b & 0x80).zero?
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
@bitmask = bitmask
|
|
498
|
+
@offset_tbl_start = p
|
|
499
|
+
@parsed = true
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Return the absolute byte offset of the field for `key`, or nil.
|
|
503
|
+
def field_offset(key)
|
|
504
|
+
slot = @reader.key_index[key]
|
|
505
|
+
return nil unless slot
|
|
506
|
+
|
|
507
|
+
# Delegate to Reader's scan logic (same implementation, avoids duplication)
|
|
508
|
+
@reader._scan_offset(@reader.data, @offset, slot)
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: nyxis
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Micael Malta
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
Pure-Ruby reader for NXB files produced by the NXS compiler. Provides
|
|
15
|
+
zero-copy memory-mapped access to typed records with O(1) random access
|
|
16
|
+
via the tail-index.
|
|
17
|
+
email:
|
|
18
|
+
- micael@example.com
|
|
19
|
+
executables: []
|
|
20
|
+
extensions: []
|
|
21
|
+
extra_rdoc_files: []
|
|
22
|
+
files:
|
|
23
|
+
- LICENSE
|
|
24
|
+
- README.md
|
|
25
|
+
- nxs.rb
|
|
26
|
+
homepage: https://github.com/nyxis-io/nyxis-drivers
|
|
27
|
+
licenses:
|
|
28
|
+
- BUSL-1.1
|
|
29
|
+
metadata:
|
|
30
|
+
source_code_uri: https://github.com/nyxis-io/nyxis-drivers
|
|
31
|
+
changelog_uri: https://github.com/nyxis-io/nyxis-drivers/releases
|
|
32
|
+
post_install_message:
|
|
33
|
+
rdoc_options: []
|
|
34
|
+
require_paths:
|
|
35
|
+
- "."
|
|
36
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.0'
|
|
41
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0'
|
|
46
|
+
requirements: []
|
|
47
|
+
rubygems_version: 3.5.22
|
|
48
|
+
signing_key:
|
|
49
|
+
specification_version: 4
|
|
50
|
+
summary: Zero-copy reader for the Nyxis (NXS) binary format
|
|
51
|
+
test_files: []
|