jsondb-rb 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: de84df565ca367ee562fe727f6d17ad1ff1a0678b907d0112e5cf81064049e90
4
+ data.tar.gz: 1b2e775b6b101f184e14347175a632d132fbb81c0d6ea3a9933575d63e4b09ce
5
+ SHA512:
6
+ metadata.gz: d8006be3f0bf9c38f6c9b62e52491a2bdaa3254d5dd6c4b649a92c7c3807289af9910ca0b6786be2b8419ae30a4385adc2c55a361254f54c3c8a132f52b00072
7
+ data.tar.gz: f464b7f71a7e77023786060534816f85c660cf15372bc8b67a7da83b83d2830f4896d1214ba9b9755407eb7f98ef2e4e9ed55b422d9642df563be9fd33866391
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shiningray
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # jsondb-rb
2
+
3
+ [![CI](https://github.com/ShiningRay/jsondb-rb/actions/workflows/ci.yml/badge.svg)](https://github.com/ShiningRay/jsondb-rb/actions/workflows/ci.yml)
4
+
5
+ 把一个 JSON 文件当数据库读写的 Ruby 库。零运行时依赖(纯标准库),MongoDB-lite 查询语法,落盘永远原子。
6
+
7
+ > gem 名 `jsondb-rb`(rubygems 的 `jsondb` 已被 2014 年旧库占用);require 路径与模块名不变:`require 'jsondb'` → `JsonDb`。
8
+
9
+ ```ruby
10
+ require 'jsondb'
11
+
12
+ db = JsonDb.open('app.json')
13
+ users = db[:users]
14
+
15
+ users.insert(name: 'Ann', age: 31, tags: %w[ruby db])
16
+ users.insert_many([{ name: 'Bob', age: 17 }, { name: 'Cara', age: 45 }])
17
+
18
+ users.where(age: { gte: 18 }).order(:name).limit(10).to_a
19
+ users.where('profile.city' => 'SZ', age: (18..60)).count
20
+ users.where('$or' => [{ vip: true }, { 'score' => { '$gte' => 90 } }]).pluck(:name)
21
+
22
+ users.update_many({ age: { '$lt' => 18 } }, '$set' => { 'minor' => true })
23
+ users.upsert({ name: 'Ann' }, '$inc' => { 'login_count' => 1 })
24
+
25
+ db.transaction do
26
+ db[:orders].insert(user: 'Ann', total: 99)
27
+ db[:users].update_one({ name: 'Ann' }, '$push' => { 'orders' => 'o-1' })
28
+ end # 一次落盘;块内抛错自动回滚
29
+
30
+ db.close
31
+ ```
32
+
33
+ 数据文件长什么样,人可以直接编辑(改完下次读取自动重载):
34
+
35
+ ```json
36
+ {
37
+ "users": [
38
+ { "name": "Ann", "age": 31, "_id": "5f3a…" }
39
+ ]
40
+ }
41
+ ```
42
+
43
+ ## 设计
44
+
45
+ - **单文件**:顶层对象,键 = 集合名,值 = 文档数组。备份就是复制文件。
46
+ - **内存权威**:读写都走内存;写操作后默认即时落盘(`autoflush: true`)。
47
+ - **原子落盘**:同目录临时文件 → `fsync` → `rename` → 目录 `fsync`。任何时刻磁盘上只有完整的旧文件或新文件,绝无半截 JSON。
48
+ - **`_id`**:文档唯一键;插入时缺省自动生成 24 位 hex。可自定义(`insert(_id: 'u1', …)`),不可变更。
49
+ - **键与值的字符串化**:symbol 键/值入库时深度转为字符串(`name: :active` ↔ `"active"`),保证 JSON 往返一致;查询过滤器里的 symbol 自动同样处理。
50
+
51
+ ## 查询
52
+
53
+ 链式:`where(...).where(...)`(AND 叠加)`order` / `limit` / `offset` / `pluck` / `first` / `count` / `exists?`,惰性执行,枚举时取快照。
54
+
55
+ 字段名支持点路径,含数组下标:`'profile.city'`、`'tags.0'`。
56
+
57
+ 操作符支持两种写法:字符串 `'$gt'` 与裸符号 `gt:`(`age: { gte: 18 }`)。显式 `$` 前缀写错操作符名会抛错;裸符号不在操作符表内则按数据 Hash 等值匹配。
58
+
59
+ | 过滤写法 | 含义 |
60
+ |---|---|
61
+ | `age: 31` | 等值 |
62
+ | `age: { '$gt' => 18 }`(`$gte` `$lt` `$lte` 同理) | 比较;缺失/类型不合不匹配、不抛错 |
63
+ | `age: (18..60)` | 区间(Range) |
64
+ | `name: { '$ne' => 'Ann' }` | 不等(缺失字段也算匹配) |
65
+ | `age: { '$in' => [18, 31] }` / `'$nin'` | 枚举(元素可为 Regexp) |
66
+ | `active: { '$exists' => true }` | 键存在性(值为 false 也算存在) |
67
+ | `name: { '$regex' => /^A/ }` | 正则(String 或 Regexp) |
68
+ | `tags: { '$size' => 2 }` / `'$all' => [...]` | 数组长度 / 全包含 |
69
+ | `age: { '$type' => 'integer' }` | JSON 类型(null/boolean/integer/float/string/array/object) |
70
+ | `age: { '$not' => { '$gte' => 18 } }` | 字段级取反 |
71
+ | `'$and' / '$or' / '$nor'` | 逻辑组合(空 `$or` 永不匹配) |
72
+ | `name: /^A/`(直接 Regexp 值) | 等价 `$regex` |
73
+
74
+ 排序对混合类型安全(`false < true < 数值 < 字符串 < 数组 < 对象 < nil`,nil/缺失垫底;平键保持插入序)。`order(:name)`、`order(:age, :desc)`、`order(age: :desc, name: :asc)` 均可,多次调用逐键追加。
75
+
76
+ ## 更新
77
+
78
+ | 写法 | 含义 |
79
+ |---|---|
80
+ | `update_one(f, { age: 32 })` | 裸 Hash = 按字段赋值(值整体替换) |
81
+ | `'$set' => { 'a.b' => v }` | 点路径赋值,中间缺失自动补 `{}` |
82
+ | `'$unset' => { 'a.b' => '' }`(或数组) | 删键 |
83
+ | `'$inc' => { 'n' => 1 }` | 数值增减(缺失视 0;目标非数值抛错,整批不落地) |
84
+ | `'$push' => { 'tags' => v }` / `{ '$each' => [...] }` | 追加数组元素 |
85
+ | `'$pull' => { 'tags' => v }` | 删等值元素;Hash 值按子过滤器删 |
86
+
87
+ `update_one` / `update_many`(批量)、`update(id, changes)`(按 `_id`)、`upsert(filter, changes)`(无则插:过滤条件 ⊕ 更新集合成文档)。操作符中途失败时该次调用全部不落地(先在副本上应用成功再写入)。
88
+
89
+ 删除:`delete(id)` / `delete_one(filter)` / `delete_many(filter)` / `clear!`;`db.drop(:name)` 连集合一起删。
90
+
91
+ ## 持久化与事务
92
+
93
+ - `JsonDb.open(path, pretty: true, autoflush: true)`;`pretty: false` 出紧凑 JSON。
94
+ - 批量写入:`JsonDb.open(path, autoflush: false)` 后手动 `db.flush`;或用 `db.transaction { … }`(块内禁即时落盘,正常结束一次落盘,抛错整体回滚到事务前)。
95
+ - `db.close` 落盘并封存实例;`db.reload!` 丢弃内存改动强制重读。
96
+ - 外部改动:无未落盘改动时,下次读取自动察觉(mtime+size)并重载——手编文件、多进程读都新鲜。本地有未落盘改动且文件又被他方改写时,打印一次警告,落盘以本地为准。
97
+
98
+ ## 并发语义(如实声明)
99
+
100
+ - 单进程多线程:安全(Monitor 串行化,含 `fork` 前后语义)。
101
+ - 跨进程:写经数据文件旁的 `<path>.lock` flock 串行化,落盘原子;**多进程并发写为文件级 last-write-wins,不合并**——两进程同时各自写不同文档,后落盘者覆盖整文件。写者之间靠 mtime 重载尽量累积对方改动,但读-改-写竞态窗口内的丢失不设防。需要强一致多写,请单写者或外部协调(这也是同类库 lowdb/TinyDB 的共同边界)。
102
+ - 读永远一致:任何进程任何时刻读文件,只会看到完整的某一版。
103
+
104
+ ## 约束与边界
105
+
106
+ - 文档只支持 JSON 原生类型(Hash/Array/String/Numeric/Boolean/nil);Date/Time 等请自行转字符串。symbol 自动转字符串。
107
+ - 无索引:每次查询全集合扫描。万级文档以下舒适,十万级请上真数据库。
108
+ - `$elemMatch`、投影(projection)、聚合:暂无,见需再加。
109
+
110
+ ## 安装
111
+
112
+ ```ruby
113
+ # Gemfile(gem 名 jsondb-rb,require 路径 jsondb)
114
+ gem 'jsondb-rb', '~> 0.1'
115
+ ```
116
+
117
+ ```sh
118
+ gem install jsondb-rb # 然后 require 'jsondb'
119
+ ```
120
+
121
+ 发版走 git tag(`v*`)触发 [release.yml](.github/workflows/release.yml),经 rubygems OIDC trusted publishing 推送。
122
+
123
+ ## 测试
124
+
125
+ ```sh
126
+ rake test # 44 例:CRUD / 查询操作符 / 持久化 / 事务 / 多线程 / 多进程 fork 并发
127
+ ```
128
+
129
+ Ruby >= 3.1,纯标准库。
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'tempfile'
5
+
6
+ module JsonDb
7
+ # 原子写:同目录临时文件 + fsync + rename + 目录 fsync。
8
+ # 读者只会看到完整的旧文件或新文件,绝无半截 JSON。
9
+ module AtomicFile
10
+ module_function
11
+
12
+ # @param path [String] 目标文件
13
+ # @param content [String] 完整文件内容(UTF-8)
14
+ def write(path, content)
15
+ directory = File.dirname(path)
16
+ FileUtils.mkdir_p(directory)
17
+ Tempfile.create(['.jsondb-', '.tmp'], directory) do |file|
18
+ file.binmode
19
+ file.write(content)
20
+ file.flush
21
+ file.fsync
22
+ File.rename(file.path, path)
23
+ end
24
+ File.open(directory, File::RDONLY, &:fsync)
25
+ path
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module JsonDb
6
+ # 文档集合:内存数组 + 经 DB 落盘。所有文档键为字符串、带唯一 _id。
7
+ class Collection
8
+ include Enumerable
9
+
10
+ attr_reader :name
11
+
12
+ # @param db [DB] 宿主库
13
+ # @param name [String, Symbol]
14
+ def initialize(db, name)
15
+ raise Error, '集合名不能为空' if name.to_s.empty?
16
+
17
+ @db = db
18
+ @name = name.to_s
19
+ end
20
+
21
+ def insert(doc)
22
+ insert_many([doc]).first
23
+ end
24
+
25
+ # @return [Array<Hash>] 实际入库的文档(字符串键、含 _id)
26
+ def insert_many(docs)
27
+ raise Error, 'insert_many 需要数组' unless docs.is_a?(Array)
28
+
29
+ stored = docs.map { |doc| prepare(doc) }
30
+ @db.mutate do |docs_of|
31
+ list = docs_of[@name] ||= []
32
+ # 先全量校验(含批内重复)再落地:失败时一条都不进
33
+ ids = stored.map { |d| d['_id'] }
34
+ if ids.uniq.size != ids.size
35
+ raise DuplicateIdError, "批内 _id 重复(集合 #{@name}):#{(ids - ids.uniq).inspect}"
36
+ end
37
+
38
+ clash = ids.find { |id| any_id?(list, id) }
39
+ raise DuplicateIdError, "_id 重复: #{clash.inspect}(集合 #{@name})" if clash
40
+
41
+ list.concat(stored)
42
+ end
43
+ stored.map { |d| Path.deep_dup(d) }
44
+ end
45
+
46
+ # 按 _id 取文档;未命中返回 nil
47
+ def find(id)
48
+ docs.find { |d| d['_id'].to_s == id.to_s }
49
+ end
50
+
51
+ def find!(id)
52
+ find(id) || (raise Error, "未找到 _id=#{id.inspect}(集合 #{@name})")
53
+ end
54
+
55
+ def where(filter = {})
56
+ Query.new(self, filter)
57
+ end
58
+
59
+ def each(&block)
60
+ return to_enum(:each) unless block
61
+
62
+ docs.each(&block)
63
+ end
64
+
65
+ def count(filter = nil)
66
+ filter ? where(filter).count : docs.size
67
+ end
68
+
69
+ alias size count
70
+
71
+ # 更新首个匹配。changes 为裸 Hash(按 $set 处理)或操作符 Hash。
72
+ # @return [Hash, nil] 更新后的文档;未命中返回 nil
73
+ def update_one(filter, changes)
74
+ update_docs(filter, changes, limit: 1).first
75
+ end
76
+
77
+ # @return [Integer] 更新条数
78
+ def update_many(filter, changes)
79
+ update_docs(filter, changes, limit: nil).size
80
+ end
81
+
82
+ def update(id, changes)
83
+ update_one({ '_id' => id.to_s }, changes)
84
+ end
85
+
86
+ # 无匹配则插入(过滤条件与更新集合成文档),有匹配则更新首条
87
+ def upsert(filter, changes)
88
+ existing = where(filter).first
89
+ return update_one(filter, changes) if existing
90
+
91
+ insert(Updater.upsert_doc(filter, changes))
92
+ end
93
+
94
+ # @return [Integer] 删除条数
95
+ def delete_one(filter)
96
+ delete_docs(filter, limit: 1)
97
+ end
98
+
99
+ def delete_many(filter)
100
+ delete_docs(filter, limit: nil)
101
+ end
102
+
103
+ def delete(id)
104
+ delete_one({ '_id' => id.to_s })
105
+ end
106
+
107
+ # 清空集合(集合保留)
108
+ def clear!
109
+ @db.mutate { |docs_of| docs_of[@name].clear }
110
+ self
111
+ end
112
+
113
+ def empty?
114
+ docs.empty?
115
+ end
116
+
117
+ # 快照:外部改动会在取快照前被察觉并重载(无本地未落盘改动时)
118
+ # @return [Array<Hash>] 文档数组副本(浅拷贝,元素为共享文档——勿就地修改)
119
+ def docs
120
+ @db.docs_snapshot(@name)
121
+ end
122
+
123
+ def to_s
124
+ "#<JsonDb::Collection #{@name} size=#{size}>"
125
+ end
126
+ alias inspect to_s
127
+
128
+ private
129
+
130
+ def prepare(doc)
131
+ raise Error, '文档必须是 Hash' unless doc.is_a?(Hash)
132
+
133
+ stored = Path.stringify(doc)
134
+ stored['_id'] ||= new_id
135
+ stored['_id'] = stored['_id'].to_s
136
+ raise Error, '_id 不能为空' if stored['_id'].empty?
137
+
138
+ stored
139
+ end
140
+
141
+ def new_id
142
+ SecureRandom.hex(12)
143
+ end
144
+
145
+ def any_id?(list, id)
146
+ list.any? { |d| d['_id'] == id }
147
+ end
148
+
149
+ def update_docs(filter, changes, limit:)
150
+ updated = []
151
+ @db.mutate do |docs_of|
152
+ list = docs_of[@name]
153
+ matched = list.select { |doc| Matcher.match?(doc, filter) }
154
+ matched = matched.first(1) if limit == 1
155
+ # 先在副本上全部应用成功,再换入:操作符中途抛错不会留下半改状态
156
+ updated = matched.map { |doc| Updater.apply!(Path.deep_dup(doc), changes) }
157
+ matched.zip(updated).each { |doc, new_doc| doc.replace(new_doc) }
158
+ updated.map! { |d| Path.deep_dup(d) }
159
+ end
160
+ updated
161
+ end
162
+
163
+ def delete_docs(filter, limit:)
164
+ removed = []
165
+ @db.mutate do |docs_of|
166
+ list = docs_of[@name]
167
+ list.reject! do |doc|
168
+ hit = Matcher.match?(doc, filter) && (limit.nil? || removed.size < limit)
169
+ removed << doc if hit
170
+ hit
171
+ end
172
+ end
173
+ removed.size
174
+ end
175
+ end
176
+ end
data/lib/jsondb/db.rb ADDED
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'monitor'
5
+ require 'fileutils'
6
+
7
+ module JsonDb
8
+ # JSON 文件数据库。
9
+ #
10
+ # 文件格式:顶层对象,键=集合名,值=文档数组。人可直接阅读编辑。
11
+ # 持久化:内存为权威,写操作后原子落盘(同目录临时文件+fsync+rename)。
12
+ # 并发:进程内 Monitor 串行化;跨进程写经 .lock 文件 flock 串行化,
13
+ # 读者只见完整文件;多进程并发写为文件级 last-write-wins(无合并)。
14
+ class DB
15
+ attr_reader :path
16
+
17
+ # @param path [String, Pathname] 数据文件路径(不存在则新建)
18
+ # @param pretty [Boolean] 落盘是否缩进美化(默认开,便于人读;大数据可关)
19
+ # @param autoflush [Boolean] 每次写操作后立即落盘(默认开;批量写可关后手动 flush)
20
+ def initialize(path = 'jsondb.json', pretty: true, autoflush: true)
21
+ @path = File.expand_path(path.to_s)
22
+ @pretty = pretty
23
+ @autoflush = autoflush
24
+ @monitor = Monitor.new
25
+ @collections = {}
26
+ @dirty = false
27
+ @in_transaction = false
28
+ @loaded_stamp = nil
29
+ @warned_conflict = false
30
+ @closed = false
31
+ load
32
+ end
33
+
34
+ # 取集合(不物化:真正出现于 collections 要等首个文档写入)
35
+ def [](name)
36
+ ensure_open
37
+ name = name.to_s
38
+ raise Error, '集合名不能为空' if name.empty?
39
+
40
+ Collection.new(self, name)
41
+ end
42
+
43
+ alias collection []
44
+
45
+ def collection?(name)
46
+ ensure_open
47
+ @monitor.synchronize { @collections.key?(name.to_s) }
48
+ end
49
+
50
+ # @return [Array<String>] 集合名(排序)
51
+ def collections
52
+ ensure_open
53
+ @monitor.synchronize { refresh_if_clean!; @collections.keys.sort }
54
+ end
55
+
56
+ # 删除整个集合(含文档)
57
+ # @return [Boolean] 是否删除了集合
58
+ def drop(name)
59
+ ensure_open
60
+ mutate { |cols| !cols.delete(name.to_s).nil? }
61
+ end
62
+
63
+ # @return [Integer] 全库文档总数
64
+ def size
65
+ ensure_open
66
+ @monitor.synchronize do
67
+ refresh_if_clean!
68
+ @collections.values.sum(&:size)
69
+ end
70
+ end
71
+
72
+ # 强制从磁盘重载,丢弃内存中未落盘的改动
73
+ def reload!
74
+ ensure_open
75
+ @monitor.synchronize { load }
76
+ self
77
+ end
78
+
79
+ # 把内存改动落盘;无改动时为 no-op
80
+ # @return [Boolean] 是否真的写了文件
81
+ def flush
82
+ ensure_open
83
+ @monitor.synchronize do
84
+ return false unless @dirty
85
+
86
+ write_file
87
+ true
88
+ end
89
+ end
90
+
91
+ # 事务:块内 autoflush 关闭,异常回滚到事务前状态,正常结束统一落盘。
92
+ # 嵌套事务抛错。返回块值。
93
+ def transaction
94
+ raise Error, '不能嵌套事务' if @in_transaction
95
+
96
+ @monitor.synchronize do
97
+ ensure_open
98
+ refresh_if_clean!
99
+ snapshot = Path.deep_dup(@collections)
100
+ prev_autoflush = @autoflush
101
+ @autoflush = false
102
+ @in_transaction = true
103
+ begin
104
+ result = yield self
105
+ flush
106
+ result
107
+ rescue Exception => e # rubocop:disable Lint/RescueException 回滚须覆盖一切退出路径
108
+ @collections = snapshot
109
+ @dirty = true # 保守置脏:快照可能含事务前的未落盘改动
110
+ raise e
111
+ ensure
112
+ @autoflush = prev_autoflush
113
+ @in_transaction = false
114
+ end
115
+ end
116
+ end
117
+
118
+ # 落盘并关闭(此后本实例不可再用)
119
+ def close
120
+ flush
121
+ @monitor.synchronize { @closed = true }
122
+ nil
123
+ end
124
+
125
+ def closed?
126
+ @closed
127
+ end
128
+
129
+ def to_s
130
+ "#<JsonDb::DB #{path} collections=#{collections.size} size=#{size}>"
131
+ end
132
+ alias inspect to_s
133
+
134
+ # ---- 内部:Collection 经此读写,保证锁纪律 ----
135
+
136
+ # 供 Collection 读快照:先察觉外部改动(无未落盘改动时自动重载)
137
+ def docs_snapshot(name)
138
+ ensure_open
139
+ @monitor.synchronize do
140
+ refresh_if_clean!
141
+ (@collections[name.to_s] || []).dup
142
+ end
143
+ end
144
+
145
+ # 供 Collection 写:块内拿到活集合表(键→数组),块返回后置脏并按需落盘
146
+ def mutate
147
+ ensure_open
148
+ @monitor.synchronize do
149
+ warn_conflict_once!
150
+ refresh_if_clean!
151
+ @dirty = true # 先置脏:块中途抛错时已发生的改动不会被静默丢弃
152
+ result = yield @collections
153
+ flush if @autoflush && !@in_transaction
154
+ result
155
+ end
156
+ end
157
+
158
+ # 本地有未落盘改动而文件又被他方改写:落盘将以本地为准(last-write-wins),只警一次
159
+ def warn_conflict_once!
160
+ return unless @dirty && !@warned_conflict && disk_stamp != @loaded_stamp
161
+
162
+ @warned_conflict = true
163
+ warn '[jsondb] 外部改动与本地未落盘改动并存,落盘将以本地为准(last-write-wins)'
164
+ nil
165
+ end
166
+
167
+ private
168
+
169
+ def load
170
+ if File.file?(@path)
171
+ parsed =
172
+ begin
173
+ JSON.parse(File.read(@path))
174
+ rescue JSON::ParserError => e
175
+ raise Error, "数据文件损坏(非合法 JSON): #{@path}:#{e.message}"
176
+ end
177
+ unless parsed.is_a?(Hash) && parsed.values.all? { |v| v.is_a?(Array) }
178
+ raise Error, "数据文件结构不符:顶层须为 {集合名: [文档, ...]}:#{@path}"
179
+ end
180
+ @collections = parsed
181
+ else
182
+ @collections = {}
183
+ end
184
+ @dirty = false
185
+ @loaded_stamp = disk_stamp
186
+ nil
187
+ end
188
+
189
+ def write_file
190
+ content = @pretty ? JSON.pretty_generate(@collections) : JSON.generate(@collections)
191
+ # 锁文件先于数据文件创建:先确保父目录存在(首写时目录可能尚无)
192
+ FileUtils.mkdir_p(File.dirname(@path))
193
+ # 每次落盘重开锁文件:fork 出的子进程不共享文件描述,flock 才真正互斥
194
+ File.open("#{@path}.lock", File::RDWR | File::CREAT, 0o644) do |lock|
195
+ lock.flock(File::LOCK_EX)
196
+ AtomicFile.write(@path, content)
197
+ end
198
+ @loaded_stamp = disk_stamp
199
+ @dirty = false
200
+ nil
201
+ end
202
+
203
+ def refresh_if_clean!
204
+ return if @dirty
205
+
206
+ load if disk_stamp != @loaded_stamp
207
+ end
208
+
209
+ # [mtime, size] 元组:File::Stat 未定义 ==,直接比对象永远不等
210
+ def disk_stamp
211
+ stat = File.stat(@path)
212
+ [stat.mtime.to_f, stat.size]
213
+ rescue Errno::ENOENT
214
+ nil
215
+ end
216
+
217
+ def ensure_open
218
+ raise Error, '库已关闭' if @closed
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonDb
4
+ # 过滤器匹配:MongoDB-lite 语义。
5
+ # 顶层多键隐含 AND;'$and'/'$or'/'$nor'/'$not' 组合;
6
+ # 字段值 Hash 全 $ 键 → 操作符映射;Range → gte+lte;Regexp → regex。
7
+ module Matcher
8
+ LOGICAL = %w[$and $or $nor $not].freeze
9
+ OPERATORS = %w[$eq $ne $gt $gte $lt $lte $in $nin $exists $regex $size $all $type].freeze
10
+
11
+ module_function
12
+
13
+ def match?(doc, filter)
14
+ filter = Path.stringify(filter)
15
+ filter.all? do |key, cond|
16
+ case key
17
+ when '$and' then cond.all? { |sub| match?(doc, sub) }
18
+ when '$or' then !cond.empty? && cond.any? { |sub| match?(doc, sub) }
19
+ when '$nor' then cond.none? { |sub| match?(doc, sub) }
20
+ when '$not' then !match?(doc, cond)
21
+ else match_field(doc, key, cond)
22
+ end
23
+ end
24
+ end
25
+
26
+ def match_field(doc, field, cond)
27
+ exists = Path.exists?(doc, field)
28
+ value = Path.get(doc, field)
29
+
30
+ case cond
31
+ when Hash
32
+ return false if cond.empty? # {} 按等值处理:字段须恰为空 Hash
33
+
34
+ ops = operator_map(cond)
35
+ if ops
36
+ ops.all? { |op, arg| apply_operator(doc, field, exists, value, op, arg) }
37
+ else
38
+ exists && value == cond # 普通嵌套 Hash:整体等值
39
+ end
40
+ when Range then exists && cover_if_comparable(value, cond)
41
+ when Regexp then exists && value.is_a?(String) && cond.match?(value)
42
+ else exists && value == cond
43
+ end
44
+ end
45
+
46
+ # 值位 Hash 的键全为已知操作符才按操作符处理;支持 '$gt' 与裸符号 :gt 两种写法。
47
+ # 显式 $ 前缀但未知 → 报错(打字保护);裸符号未知 → 返回 nil 按等值匹配(数据型嵌套 Hash)。
48
+ def operator_map(cond)
49
+ normalized = {}
50
+ cond.each_key do |key|
51
+ name = key.to_s
52
+ explicit = name.start_with?('$')
53
+ name = "$#{name}" unless explicit
54
+ if OPERATORS.include?(name) || name == '$not'
55
+ normalized[name] = cond[key]
56
+ elsif explicit
57
+ raise Error, "不支持的操作符: #{name.inspect}"
58
+ else
59
+ return nil
60
+ end
61
+ end
62
+ normalized
63
+ end
64
+
65
+ def apply_operator(doc, field, exists, value, op, arg)
66
+ case op
67
+ when '$eq' then exists && value == Path.stringify(arg)
68
+ when '$ne' then !exists || value != Path.stringify(arg)
69
+ when '$gt' then exists && ordered?(value, arg) { |c| c > 0 }
70
+ when '$gte' then exists && ordered?(value, arg) { |c| c >= 0 }
71
+ when '$lt' then exists && ordered?(value, arg) { |c| c < 0 }
72
+ when '$lte' then exists && ordered?(value, arg) { |c| c <= 0 }
73
+ when '$in' then arg.any? { |v| member?(value, exists, v) }
74
+ when '$nin' then arg.none? { |v| member?(value, exists, v) }
75
+ when '$exists' then !!exists == truthy(arg)
76
+ when '$regex' then regex_match(exists, value, arg)
77
+ when '$size' then value.is_a?(Array) && value.size == arg
78
+ when '$all' then value.is_a?(Array) && arg.all? { |v| value.include?(Path.stringify(v)) }
79
+ when '$type' then exists && json_type(value) == arg.to_s
80
+ when '$not' then !match_field(doc, field, arg)
81
+ else raise Error, "不支持的操作符: #{op.inspect}(字段 #{field.inspect})"
82
+ end
83
+ end
84
+
85
+ def member?(value, exists, candidate)
86
+ return false unless exists
87
+
88
+ candidate = Path.stringify(candidate)
89
+ if candidate.is_a?(Regexp)
90
+ value.is_a?(String) && candidate.match?(value)
91
+ else
92
+ value == candidate
93
+ end
94
+ end
95
+
96
+ # Range 只对 Numeric/String 生效;类型不合返回 false 而非抛错
97
+ def cover_if_comparable(value, range)
98
+ return false unless range_coverable?(value, range)
99
+
100
+ range.cover?(value)
101
+ end
102
+
103
+ def range_coverable?(value, range)
104
+ (value.is_a?(Numeric) && range.first.is_a?(Numeric)) ||
105
+ (value.is_a?(String) && range.first.is_a?(String))
106
+ end
107
+
108
+ # 同族(同为 Numeric 或同为 String)才可比;否则 nil(判定不匹配)
109
+ def compare_if_same_family(a, b)
110
+ return a <=> b if a.is_a?(Numeric) && b.is_a?(Numeric)
111
+ return a <=> b if a.is_a?(String) && b.is_a?(String)
112
+
113
+ nil
114
+ end
115
+
116
+ def ordered?(value, arg)
117
+ cmp = compare_if_same_family(value, arg)
118
+ cmp && yield(cmp)
119
+ end
120
+
121
+ def regex_match(exists, value, arg)
122
+ return false unless exists && value.is_a?(String)
123
+
124
+ pattern = arg.is_a?(Regexp) ? arg : Regexp.new(arg.to_s)
125
+ pattern.match?(value)
126
+ end
127
+
128
+ def truthy(arg)
129
+ !(arg.nil? || arg == false)
130
+ end
131
+
132
+ # JSON 类型名($type 用)
133
+ def json_type(value)
134
+ case value
135
+ when NilClass then 'null'
136
+ when true, false then 'boolean'
137
+ when Integer then 'integer'
138
+ when Float then 'float'
139
+ when String then 'string'
140
+ when Array then 'array'
141
+ when Hash then 'object'
142
+ else raise Error, "非 JSON 类型: #{value.class}"
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonDb
4
+ # 点路径读写:'a.b.0.c' 在嵌套 Hash/Array 上取值、赋值、删除。
5
+ # 段全以字符串存放;纯数字段视为数组下标(仅对已存在的 Array 生效)。
6
+ module Path
7
+ module_function
8
+
9
+ # 'a.b.c' → ['a', 'b', 'c']
10
+ def segments(path)
11
+ path.to_s.split('.')
12
+ end
13
+
14
+ def exists?(doc, path)
15
+ value = doc
16
+ segments(path).each do |seg|
17
+ if value.is_a?(Array)
18
+ return false unless index?(seg, value)
19
+
20
+ value = value[seg.to_i]
21
+ elsif value.is_a?(Hash)
22
+ return false unless value.key?(seg)
23
+
24
+ value = value[seg]
25
+ else
26
+ return false
27
+ end
28
+ end
29
+ true
30
+ end
31
+
32
+ # @return 命中值;不存在返回 nil(与存 nil 无法区分,判存在用 exists?)
33
+ def get(doc, path)
34
+ value = doc
35
+ segments(path).each do |seg|
36
+ return nil unless container?(value)
37
+ return nil if value.is_a?(Array) && !index?(seg, value)
38
+
39
+ value = value.is_a?(Array) ? value[seg.to_i] : value[seg]
40
+ end
41
+ value
42
+ end
43
+
44
+ # 赋值。中间缺失的 Hash 段自动补 {};数组下标只接受已存在位置或末尾追加。
45
+ def set(doc, path, value)
46
+ segs = segments(path)
47
+ raise Error, "空路径: #{path.inspect}" if segs.empty?
48
+
49
+ target = segs[0..-2].inject(doc) do |node, seg|
50
+ next_node = node.is_a?(Array) ? node[seg.to_i] : node[seg]
51
+ if next_node.nil?
52
+ raise Error, "路径中断于 #{seg.inspect}:不可补容器" if node.is_a?(Array)
53
+
54
+ node[seg] = {}
55
+ next_node = node[seg]
56
+ end
57
+ raise Error, "路径中断于 #{seg.inspect}:非容器" unless container?(next_node)
58
+
59
+ next_node
60
+ end
61
+ last = segs.last
62
+ if target.is_a?(Array)
63
+ idx = last.to_i
64
+ raise Error, "数组下标越界: #{path}" unless idx <= target.size
65
+
66
+ idx == target.size ? target.push(value) : target[idx] = value
67
+ else
68
+ target[last] = value
69
+ end
70
+ value
71
+ end
72
+
73
+ # 删除路径值;路径不存在则静默返回 nil。
74
+ def delete(doc, path)
75
+ segs = segments(path)
76
+ return nil if segs.empty?
77
+
78
+ parent = doc
79
+ segs[0..-2].each do |seg|
80
+ return nil unless container?(parent)
81
+ return nil if parent.is_a?(Array) && !index?(seg, parent)
82
+
83
+ parent = parent.is_a?(Array) ? parent[seg.to_i] : parent[seg]
84
+ end
85
+ return nil unless container?(parent)
86
+
87
+ last = segs.last
88
+ if parent.is_a?(Array)
89
+ idx = last.to_i
90
+ idx < parent.size ? parent.delete_at(idx) : nil
91
+ else
92
+ parent.delete(last)
93
+ end
94
+ end
95
+
96
+ # 深度字符串化:入库前把 symbol key/value 统一为字符串,保证 JSON 往返一致
97
+ def stringify(value)
98
+ case value
99
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) }
100
+ when Array then value.map { |v| stringify(v) }
101
+ when Symbol then value.to_s
102
+ else value
103
+ end
104
+ end
105
+
106
+ # JSON 原生类型深拷贝(文档只含 JSON 类型,Marshal 安全且快)
107
+ def deep_dup(value)
108
+ Marshal.load(Marshal.dump(value))
109
+ end
110
+
111
+ def container?(value)
112
+ value.is_a?(Hash) || value.is_a?(Array)
113
+ end
114
+
115
+ def index?(seg, array)
116
+ /\A\d+\z/.match?(seg) && seg.to_i < array.size
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonDb
4
+ # 链式查询:where(...).order(...).limit(n).offset(n) → 惰性,枚举时取快照执行。
5
+ # 每次 where 追加 AND 条件;order 可多次调用逐键追加;结果 Enumerable。
6
+ class Query
7
+ include Enumerable
8
+
9
+ # @param collection [Collection]
10
+ # @param filter [Hash]
11
+ def initialize(collection, filter = {})
12
+ @collection = collection
13
+ @filters = [filter]
14
+ @order_specs = []
15
+ @limit_value = nil
16
+ @offset_value = 0
17
+ end
18
+
19
+ def where(filter)
20
+ @filters << filter
21
+ self
22
+ end
23
+
24
+ # order(:name) → 升序;order(age: :desc, name: :asc) 多键;order(:age, :desc) 单键带方向
25
+ def order(*specs)
26
+ spec = normalize_order_spec(specs)
27
+ spec.each { |k, dir| @order_specs << [k.to_s, dir] }
28
+ self
29
+ end
30
+
31
+ def normalize_order_spec(specs)
32
+ case specs.size
33
+ when 1
34
+ specs.first.is_a?(Hash) ? specs.first : { specs.first => :asc }
35
+ when 2
36
+ raise ArgumentError, "order 参数不合法: #{specs.inspect}" unless %i[asc desc].include?(specs.last)
37
+
38
+ { specs.first => specs.last }
39
+ else
40
+ raise ArgumentError, "order 参数不合法: #{specs.inspect}"
41
+ end
42
+ end
43
+
44
+ def limit(n)
45
+ @limit_value = Integer(n)
46
+ self
47
+ end
48
+
49
+ def offset(n)
50
+ @offset_value = Integer(n)
51
+ self
52
+ end
53
+
54
+ def each(&block)
55
+ return to_enum(:each) unless block
56
+
57
+ execute.each(&block)
58
+ end
59
+
60
+ def to_a
61
+ execute
62
+ end
63
+
64
+ def first
65
+ saved = @limit_value
66
+ @limit_value = 1
67
+ begin
68
+ execute.first
69
+ ensure
70
+ @limit_value = saved
71
+ end
72
+ end
73
+
74
+ def count
75
+ execute.size
76
+ end
77
+
78
+ def exists?
79
+ !execute.empty?
80
+ end
81
+
82
+ # pluck(:name) → [值];pluck(:a, :b) → [[a, b], ...];支持点路径
83
+ def pluck(*keys)
84
+ keys = keys.map(&:to_s)
85
+ rows = execute
86
+ if keys.size == 1
87
+ rows.map { |d| Path.get(d, keys.first) }
88
+ else
89
+ rows.map { |d| keys.map { |k| Path.get(d, k) } }
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ # 枚举时执行:集合快照 → 过滤 → 排序 → offset → limit
96
+ def execute
97
+ docs = @collection.docs
98
+ @filters.each do |filter|
99
+ docs = docs.select { |d| Matcher.match?(d, filter) }
100
+ end
101
+ docs = sort(docs) unless @order_specs.empty?
102
+ docs = docs[@offset_value..] || [] unless @offset_value.zero?
103
+ docs = docs[0, @limit_value] if @limit_value
104
+ docs
105
+ end
106
+
107
+ def sort(docs)
108
+ indexed = docs.each_with_index.to_a
109
+ indexed.sort! do |(a, ai), (b, bi)|
110
+ cmp = 0
111
+ @order_specs.each do |key, dir|
112
+ cmp = compare(Path.get(a, key), Path.get(b, key))
113
+ cmp = -cmp if dir.to_s.start_with?('desc', '-')
114
+ break unless cmp.zero?
115
+ end
116
+ # 稳定排序:键全等时保持原序(插入序)
117
+ cmp.zero? ? ai <=> bi : cmp
118
+ end
119
+ indexed.map!(&:first)
120
+ end
121
+
122
+ # 跨类型安全比较:false < true < 数值 < 字符串 < 数组 < 对象 < nil(缺失垫底,升序在末);
123
+ # 同族(数值/字符串/数组)深入比较,避免 String <=> Integer 抛错。
124
+ TYPE_RANK = { FalseClass => 0, TrueClass => 1, Numeric => 3, String => 4, Array => 5, Hash => 6, NilClass => 7 }.freeze
125
+
126
+ def compare(a, b)
127
+ ra = rank(a)
128
+ rb = rank(b)
129
+ return ra <=> rb unless ra == rb
130
+
131
+ case a
132
+ when Numeric, String then a <=> b
133
+ when Array
134
+ a.zip(b).each do |x, y|
135
+ c = compare(x, y)
136
+ return c unless c.zero?
137
+ end
138
+ a.size <=> b.size
139
+ else 0 # true/false/nil/Hash 同族视为相等档
140
+ end
141
+ end
142
+
143
+ def rank(value)
144
+ TYPE_RANK.each do |klass, r|
145
+ return r if value.is_a?(klass)
146
+ end
147
+ TYPE_RANK.size
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonDb
4
+ # 更新操作符:$set/$unset/$inc/$push/$pull;裸 Hash 视为顶层 $set。
5
+ # 字段名支持点路径;_id 不可变更(同值改写视为合法)。
6
+ module Updater
7
+ OPERATORS = %w[$set $unset $inc $push $pull].freeze
8
+
9
+ module_function
10
+
11
+ # 就地修改 doc(调用方负责传副本),返回 doc。
12
+ def apply!(doc, changes)
13
+ changes = Path.stringify(changes)
14
+ plain, ops = partition(changes)
15
+
16
+ plain.each { |k, v| guard_id!(doc, k, v); Path.set(doc, k, v) }
17
+ ops.each_key { |op| apply_operator!(doc, op, changes[op]) }
18
+ doc
19
+ end
20
+
21
+ def partition(changes)
22
+ plain = {}
23
+ ops = {}
24
+ changes.each do |k, v|
25
+ if k.to_s.start_with?('$')
26
+ ops[k.to_s] = v
27
+ else
28
+ plain[k.to_s] = v
29
+ end
30
+ end
31
+ [plain, ops]
32
+ end
33
+
34
+ def apply_operator!(doc, op, spec)
35
+ case op
36
+ when '$set'
37
+ spec.each { |k, v| guard_id!(doc, k, v); Path.set(doc, k, v) }
38
+ when '$unset'
39
+ keys(spec).each { |k| guard_id!(doc, k, nil); Path.delete(doc, k) }
40
+ when '$inc'
41
+ spec.each do |k, delta|
42
+ raise Error, "$inc 的值必须是数值: #{delta.inspect}" unless delta.is_a?(Numeric)
43
+
44
+ current = Path.get(doc, k) || 0
45
+ raise Error, "$inc 目标当前非数值: #{k.inspect}=#{current.inspect}" unless current.is_a?(Numeric)
46
+
47
+ guard_id!(doc, k, nil)
48
+ Path.set(doc, k, current + delta)
49
+ end
50
+ when '$push'
51
+ spec.each do |k, v|
52
+ values = v.is_a?(Hash) && v.key?('$each') ? v['$each'] : [v]
53
+ current = Path.get(doc, k)
54
+ current = Path.set(doc, k, []) if current.nil?
55
+ raise Error, "$push 目标当前非数组: #{k.inspect}=#{current.inspect}" unless current.is_a?(Array)
56
+
57
+ current.concat(values.map { |e| Path.stringify(e) })
58
+ end
59
+ when '$pull'
60
+ spec.each do |k, v|
61
+ current = Path.get(doc, k)
62
+ next unless current.is_a?(Array)
63
+
64
+ removed = current.reject { |el| pull_match?(el, v) }
65
+ current.replace(removed)
66
+ end
67
+ else
68
+ raise Error, "不支持的更新操作符: #{op.inspect}"
69
+ end
70
+ end
71
+
72
+ def keys(spec)
73
+ case spec
74
+ when Hash then spec.keys
75
+ when Array then spec
76
+ else raise Error, "$unset 需要 Hash 或 Array: #{spec.inspect}"
77
+ end
78
+ end
79
+
80
+ # $pull:标量按等值删;Hash 视为子过滤器(含 $ 键走操作符,否则字段等值)
81
+ def pull_match?(element, spec)
82
+ spec = Path.stringify(spec)
83
+ if element.is_a?(Hash) && spec.is_a?(Hash)
84
+ Matcher.match?(element, spec)
85
+ else
86
+ element == spec
87
+ end
88
+ end
89
+
90
+ # upsert 用的文档合成:过滤条件深度合并更新集(更新覆盖过滤)
91
+ def upsert_doc(filter, changes)
92
+ plain, ops = partition(Path.stringify(changes))
93
+ base = Path.stringify(filter).reject { |k, _| k.to_s.start_with?('$') }
94
+ merged = deep_merge(base, plain)
95
+ deep_merge(merged, ops['$set'] || {})
96
+ end
97
+
98
+ def deep_merge(base, extra)
99
+ extra.each_with_object(Path.deep_dup(base)) do |(k, v), acc|
100
+ if acc[k].is_a?(Hash) && v.is_a?(Hash)
101
+ acc[k] = deep_merge(acc[k], v)
102
+ else
103
+ acc[k] = v
104
+ end
105
+ end
106
+ end
107
+
108
+ def guard_id!(doc, key, new_value)
109
+ return unless key.to_s == '_id' && Path.exists?(doc, '_id')
110
+
111
+ raise Error, '_id 不可变更' unless new_value.nil? || new_value.to_s == doc['_id'].to_s
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JsonDb
4
+ VERSION = '0.1.0'
5
+ end
data/lib/jsondb.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'jsondb/version'
4
+ require_relative 'jsondb/atomic_file'
5
+ require_relative 'jsondb/path'
6
+ require_relative 'jsondb/matcher'
7
+ require_relative 'jsondb/updater'
8
+ require_relative 'jsondb/query'
9
+ require_relative 'jsondb/collection'
10
+ require_relative 'jsondb/db'
11
+
12
+ # jsondb:把一个 JSON 文件当数据库读写的零依赖 Ruby 库。
13
+ #
14
+ # db = JsonDb.open('app.json')
15
+ # users = db[:users]
16
+ # users.insert(name: 'Ann', age: 31)
17
+ # users.where(age: { gte: 18 }).order(:name).limit(10).to_a
18
+ # db.close
19
+ module JsonDb
20
+ class Error < StandardError; end
21
+ class DuplicateIdError < Error; end
22
+
23
+ # @see DB#initialize
24
+ def self.open(path = 'jsondb.json', **options, &block)
25
+ db = DB.new(path, **options)
26
+ return db unless block
27
+
28
+ begin
29
+ block.call(db)
30
+ ensure
31
+ db.close
32
+ end
33
+ end
34
+ end
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jsondb-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - shiningray
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: jsondb 将单个 JSON 文件作为嵌入式文档数据库:集合/文档 CRUD、点路径与查询操作符($gt/$in/$regex/$or...)、链式
13
+ where/order/limit、更新操作符($set/$inc/$push/$pull)、事务与原子落盘。纯 Ruby 标准库,无运行时依赖。
14
+ email:
15
+ - shiningray@users.noreply.github.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/jsondb.rb
23
+ - lib/jsondb/atomic_file.rb
24
+ - lib/jsondb/collection.rb
25
+ - lib/jsondb/db.rb
26
+ - lib/jsondb/matcher.rb
27
+ - lib/jsondb/path.rb
28
+ - lib/jsondb/query.rb
29
+ - lib/jsondb/updater.rb
30
+ - lib/jsondb/version.rb
31
+ homepage: https://github.com/ShiningRay/jsondb-rb
32
+ licenses:
33
+ - MIT
34
+ metadata:
35
+ homepage_uri: https://github.com/ShiningRay/jsondb-rb
36
+ source_code_uri: https://github.com/ShiningRay/jsondb-rb
37
+ rubygems_mfa_required: 'true'
38
+ rdoc_options: []
39
+ require_paths:
40
+ - lib
41
+ required_ruby_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 3.1.0
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
51
+ requirements: []
52
+ rubygems_version: 4.0.20
53
+ specification_version: 4
54
+ summary: 把一个 JSON 文件当数据库读写(零依赖、MongoDB-lite 查询)
55
+ test_files: []