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 +7 -0
- data/LICENSE +21 -0
- data/README.md +129 -0
- data/lib/jsondb/atomic_file.rb +28 -0
- data/lib/jsondb/collection.rb +176 -0
- data/lib/jsondb/db.rb +221 -0
- data/lib/jsondb/matcher.rb +146 -0
- data/lib/jsondb/path.rb +119 -0
- data/lib/jsondb/query.rb +150 -0
- data/lib/jsondb/updater.rb +114 -0
- data/lib/jsondb/version.rb +5 -0
- data/lib/jsondb.rb +34 -0
- metadata +55 -0
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
|
+
[](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
|
data/lib/jsondb/path.rb
ADDED
|
@@ -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
|
data/lib/jsondb/query.rb
ADDED
|
@@ -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
|
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: []
|