active_record_json_streamer 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.txt +21 -0
- data/README.md +64 -0
- data/lib/active_record_json_streamer/controller.rb +26 -0
- data/lib/active_record_json_streamer/railtie.rb +14 -0
- data/lib/active_record_json_streamer/streamer.rb +75 -0
- data/lib/active_record_json_streamer/version.rb +5 -0
- data/lib/active_record_json_streamer.rb +9 -0
- metadata +116 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6a470f59222637d0befa471e4d36732d148f664291ec90ee74fd5e534594050e
|
|
4
|
+
data.tar.gz: 4005d797b64d48177f3297aeef94022248c9014c2de23d05a4b8f5a82f4a562d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 594f50a9a2a7696bd0804c94ade45d8eb95f6ea13c4c5eb8afd11a7bcd283b55776dbea1fb37cf71012cc0fd54041f52e684d341ea701e049fa8a24e48e4fd5b
|
|
7
|
+
data.tar.gz: '059750a10d543534c5126cb0da438e04c535538f7ab6bff370f8c1a0e11a01fb95c9610e691fb108dff93016c75e03f3b6d571150bbba475ecc2722361df7963'
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mistika Team
|
|
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 A PARTICULAR PURPOSE AND LIABLE FOR
|
|
19
|
+
ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
20
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
|
|
21
|
+
THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# ActiveRecordJsonStreamer
|
|
2
|
+
|
|
3
|
+
`ActiveRecordJsonStreamer` is a lightweight Ruby gem that enables high-performance, memory-efficient JSON array HTTP streaming for large `ActiveRecord` queries using **Keyset Pagination** and **Chunked Transfer Encoding**.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Constant $O(1)$ RAM usage**: Streams records directly from the database in batches without accumulating large objects in memory.
|
|
8
|
+
- **Keyset Pagination**: Fast cursor-based pagination over composite keys `(created_at, id)` instead of slow `OFFSET` queries.
|
|
9
|
+
- **Rails Integration**: Out-of-the-box support for `ActionController::Base` and `ActionController::API`.
|
|
10
|
+
- **Flexible Field Mapping**: Pass a block to customize the serialized JSON payload per record.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Add this line to your application's `Gemfile`:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
gem "active_record_json_streamer"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
And then execute:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
$ bundle install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### In Rails Controllers
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
class Admin::ExportLogsController < ApplicationController
|
|
32
|
+
include ActiveRecordJsonStreamer::Controller
|
|
33
|
+
|
|
34
|
+
def export
|
|
35
|
+
logs = UserActivityLog.where(created_at: 30.days.ago..)
|
|
36
|
+
|
|
37
|
+
stream_json_export(logs, filename: "logs.json", batch_size: 1000) do |log|
|
|
38
|
+
{
|
|
39
|
+
id: log.id,
|
|
40
|
+
action: log.action,
|
|
41
|
+
user_id: log.user_id,
|
|
42
|
+
created_at: log.created_at.iso8601
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Options
|
|
50
|
+
|
|
51
|
+
| Parameter | Type | Default | Description |
|
|
52
|
+
|---|---|---|---|
|
|
53
|
+
| `relation` | `ActiveRecord::Relation` | *Required* | The query relation to stream. |
|
|
54
|
+
| `filename` | `String` | *Required* | Attachment filename for Content-Disposition header. |
|
|
55
|
+
| `batch_size` | `Integer` | `1000` | Number of records to load per database query batch. |
|
|
56
|
+
| `limit` | `Integer` / `nil` | `50_000` | Maximum total records to stream (`nil` for unlimited). |
|
|
57
|
+
| `cursor_column` | `Symbol` | `:created_at` | Primary sort/cursor column. |
|
|
58
|
+
| `primary_key` | `Symbol` | `:id` | Secondary sort/cursor column (unique identifier). |
|
|
59
|
+
| `order` | `Symbol` | `:desc` | Sort direction (`:desc` or `:asc`). |
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
This gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
64
|
+
# active_record_json_streamer
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecordJsonStreamer
|
|
4
|
+
module Controller
|
|
5
|
+
DEFAULT_LIMIT = 50_000
|
|
6
|
+
|
|
7
|
+
def stream_json_export(relation, filename:, batch_size: 1000, limit: DEFAULT_LIMIT, cursor_column: :created_at, primary_key: :id, order: :desc, &field_mapper)
|
|
8
|
+
response.headers["Content-Type"] = "application/json"
|
|
9
|
+
response.headers["Content-Disposition"] = %(attachment; filename="#{filename}")
|
|
10
|
+
response.headers["Cache-Control"] = "no-cache"
|
|
11
|
+
response.headers["Last-Modified"] = Time.now.httpdate
|
|
12
|
+
|
|
13
|
+
streamer = Streamer.new(
|
|
14
|
+
relation,
|
|
15
|
+
batch_size: batch_size,
|
|
16
|
+
limit: limit,
|
|
17
|
+
cursor_column: cursor_column,
|
|
18
|
+
primary_key: primary_key,
|
|
19
|
+
order: order,
|
|
20
|
+
&field_mapper
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
self.response_body = streamer.enumerator
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecordJsonStreamer
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
initializer "active_record_json_streamer.controller" do
|
|
6
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
7
|
+
include ActiveRecordJsonStreamer::Controller
|
|
8
|
+
end
|
|
9
|
+
ActiveSupport.on_load(:action_controller_api) do
|
|
10
|
+
include ActiveRecordJsonStreamer::Controller
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module ActiveRecordJsonStreamer
|
|
6
|
+
class Streamer
|
|
7
|
+
DEFAULT_BATCH_SIZE = 1000
|
|
8
|
+
DEFAULT_LIMIT = 50_000
|
|
9
|
+
|
|
10
|
+
attr_reader :relation, :batch_size, :limit, :cursor_column, :primary_key, :order, :field_mapper
|
|
11
|
+
|
|
12
|
+
def initialize(relation, batch_size: DEFAULT_BATCH_SIZE, limit: DEFAULT_LIMIT, cursor_column: :created_at, primary_key: :id, order: :desc, &field_mapper)
|
|
13
|
+
@relation = relation
|
|
14
|
+
@batch_size = batch_size
|
|
15
|
+
@limit = limit
|
|
16
|
+
@cursor_column = cursor_column
|
|
17
|
+
@primary_key = primary_key
|
|
18
|
+
@order = order.to_sym
|
|
19
|
+
@field_mapper = field_mapper
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def enumerator
|
|
23
|
+
Enumerator.new do |yielder|
|
|
24
|
+
yielder << "["
|
|
25
|
+
first = true
|
|
26
|
+
emitted = 0
|
|
27
|
+
|
|
28
|
+
col = relation.connection.quote_column_name(cursor_column)
|
|
29
|
+
pk = relation.connection.quote_column_name(primary_key)
|
|
30
|
+
tbl = relation.connection.quote_table_name(relation.table_name)
|
|
31
|
+
col_ref = "#{tbl}.#{col}"
|
|
32
|
+
pk_ref = "#{tbl}.#{pk}"
|
|
33
|
+
|
|
34
|
+
current_relation = relation.order(cursor_column => order, primary_key => order)
|
|
35
|
+
|
|
36
|
+
loop do
|
|
37
|
+
remaining = limit - emitted if limit
|
|
38
|
+
break if remaining && remaining <= 0
|
|
39
|
+
|
|
40
|
+
fetch_size = remaining ? [ batch_size, remaining ].min : batch_size
|
|
41
|
+
batch = current_relation.limit(fetch_size).to_a
|
|
42
|
+
break if batch.empty?
|
|
43
|
+
|
|
44
|
+
batch.each do |record|
|
|
45
|
+
yielder << "," unless first
|
|
46
|
+
first = false
|
|
47
|
+
data = field_mapper ? field_mapper.call(record) : record.as_json
|
|
48
|
+
yielder << JSON.generate(data)
|
|
49
|
+
emitted += 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
break if batch.size < fetch_size
|
|
53
|
+
|
|
54
|
+
last = batch.last
|
|
55
|
+
last_cursor = last.public_send(cursor_column)
|
|
56
|
+
last_pk = last.public_send(primary_key)
|
|
57
|
+
|
|
58
|
+
if order == :desc
|
|
59
|
+
current_relation = relation.where(
|
|
60
|
+
"(#{col_ref} < :ca) OR (#{col_ref} = :ca AND #{pk_ref} < :pk)",
|
|
61
|
+
ca: last_cursor, pk: last_pk
|
|
62
|
+
).order(cursor_column => :desc, primary_key => :desc)
|
|
63
|
+
else
|
|
64
|
+
current_relation = relation.where(
|
|
65
|
+
"(#{col_ref} > :ca) OR (#{col_ref} = :ca AND #{pk_ref} > :pk)",
|
|
66
|
+
ca: last_cursor, pk: last_pk
|
|
67
|
+
).order(cursor_column => :asc, primary_key => :asc)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
yielder << "]"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "active_record_json_streamer/version"
|
|
4
|
+
require_relative "active_record_json_streamer/streamer"
|
|
5
|
+
require_relative "active_record_json_streamer/controller"
|
|
6
|
+
require_relative "active_record_json_streamer/railtie" if defined?(Rails::Railtie)
|
|
7
|
+
|
|
8
|
+
module ActiveRecordJsonStreamer
|
|
9
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: active_record_json_streamer
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Alex Abramov
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: actionpack
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rspec
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.12'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '3.12'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: sqlite3
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: rake
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
description: Stream large ActiveRecord relations as JSON arrays over HTTP using chunked
|
|
83
|
+
response bodies and Keyset pagination without OOM.
|
|
84
|
+
executables: []
|
|
85
|
+
extensions: []
|
|
86
|
+
extra_rdoc_files: []
|
|
87
|
+
files:
|
|
88
|
+
- LICENSE.txt
|
|
89
|
+
- README.md
|
|
90
|
+
- lib/active_record_json_streamer.rb
|
|
91
|
+
- lib/active_record_json_streamer/controller.rb
|
|
92
|
+
- lib/active_record_json_streamer/railtie.rb
|
|
93
|
+
- lib/active_record_json_streamer/streamer.rb
|
|
94
|
+
- lib/active_record_json_streamer/version.rb
|
|
95
|
+
licenses:
|
|
96
|
+
- MIT
|
|
97
|
+
metadata: {}
|
|
98
|
+
rdoc_options: []
|
|
99
|
+
require_paths:
|
|
100
|
+
- lib
|
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: 3.0.0
|
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0'
|
|
111
|
+
requirements: []
|
|
112
|
+
rubygems_version: 4.0.16
|
|
113
|
+
specification_version: 4
|
|
114
|
+
summary: High-performance memory-efficient JSON array streaming for ActiveRecord with
|
|
115
|
+
Keyset pagination
|
|
116
|
+
test_files: []
|