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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecordJsonStreamer
4
+ VERSION = "0.1.0"
5
+ 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: []