turbojson 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: 9ceaa05c135c84cc26f2b51912b98901841146dee317aa371c81ed752de1b1cf
4
+ data.tar.gz: 2c3d2b7af3d7b3bd7e270401c49ec5eff2f590a7a7bad17cac895e6d6e287dd4
5
+ SHA512:
6
+ metadata.gz: 639625b06b99467f6714620a7a9bfe2bb4b1667b706834bfd56c9950a6fb8c56a1d0709fa8de95616e2bb716f6d705a38bf5719e1d06b97e0c0aecf7d7a805e7
7
+ data.tar.gz: 284b34ac41fedb2f1b37b547efda528a644bdb91c9a40b4534161f7eac66a7ebd2def8602080a1273aa6e2836cb90fceea97b827e8a7170567ff6784023350f7
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # turbojson
2
+
3
+ Compile a Ruby serializer DSL into fast PostgreSQL JSON SQL for read-heavy APIs. Define attributes and associations in Ruby, then generate SQL that returns JSON without instantiating thousands of ActiveRecord objects.
4
+
5
+ ## Why
6
+
7
+ ActiveRecord is excellent for writes and business logic, but JSON APIs are often read-heavy. Hydrating large object graphs just to serialize them is slow and memory-intensive. `turbojson` lets you keep a familiar serializer DSL while shifting JSON construction to PostgreSQL (`json_build_object`, `json_agg`).
8
+
9
+ ## Install
10
+
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "turbojson"
15
+ ```
16
+
17
+ Or use a local path during development:
18
+
19
+ ```ruby
20
+ gem "turbojson", path: "/path/to/turbojson"
21
+ ```
22
+
23
+ Then:
24
+
25
+ ```bash
26
+ bundle install
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ Define serializers:
32
+
33
+ ```ruby
34
+ class CategorySerializer < Turbojson::Serializer
35
+ model Category
36
+ attributes :id, :name
37
+ end
38
+
39
+ class BarPhotoSerializer < Turbojson::Serializer
40
+ model BarPhoto
41
+ attributes :id, :url
42
+ end
43
+
44
+ class BarSerializer < Turbojson::Serializer
45
+ model Bar
46
+ attributes :id, :name
47
+
48
+ has_one :category, serializer: CategorySerializer
49
+ has_many :bar_photos, serializer: BarPhotoSerializer
50
+
51
+ attribute :distance do |scope|
52
+ "ST_Distance(#{scope.table_name}.location, 'POINT(...)')"
53
+ end
54
+ end
55
+ ```
56
+
57
+ Serialize an ActiveRecord scope:
58
+
59
+ ```ruby
60
+ scope = Bar.where(active: true).limit(10)
61
+ json = BarSerializer.serialize(scope)
62
+ ```
63
+
64
+ Generate SQL directly:
65
+
66
+ ```ruby
67
+ sql = BarSerializer.to_sql
68
+ ```
69
+
70
+ ## How It Works
71
+
72
+ 1. DSL definitions build an AST describing the model, attributes, and associations.
73
+ 2. The SQL generator compiles the AST into `json_build_object` and `json_agg` SQL.
74
+ 3. `serialize(scope)` wraps the generated SQL around your existing relation to respect `where`, `limit`, and `offset`.
75
+ 4. The database returns JSON; the gem parses it to Ruby hashes and arrays.
76
+
77
+ ## Strict Column Validation
78
+
79
+ By default, attributes must be real columns. If a name is not a column, a `Turbojson::ConfigurationError` is raised. Column checks use `schema_cache` when available, then fall back to `columns_hash`.
80
+
81
+ ## Active Storage Key Post-Processing
82
+
83
+ If you need signed URLs, fetch the key in SQL and post-process in Ruby:
84
+
85
+ ```ruby
86
+ payload = BarSerializer.serialize(scope)
87
+
88
+ signed = Turbojson::PostProcessor.process(payload) do |key|
89
+ blob = ActiveStorage::Blob.find_by!(key: key)
90
+ Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true)
91
+ end
92
+ ```
93
+
94
+ ## Performance Notes
95
+
96
+ - Uses `LATERAL` joins for associations to avoid correlated subquery hotspots.
97
+ - Avoids instantiating ActiveRecord objects for read-heavy endpoints.
98
+
99
+ ## Testing
100
+
101
+ Run the test suite:
102
+
103
+ ```bash
104
+ bundle exec rspec
105
+ ```
106
+
107
+ SQL snapshot tests live under `spec/fixtures/sql/`.
108
+
109
+ ## Roadmap
110
+
111
+ - Add ActiveModel::Serializer parity specs with a real test database.
112
+ - Expand SQL generation to handle advanced filters and custom join strategies.
113
+ - Add Benchmark.ips and memory benchmarks under `bench/`.
114
+
115
+ ## Contributing
116
+
117
+ See `AGENTS.md` for repository guidelines, structure, and development notes.
118
+
119
+ ## License
120
+
121
+ MIT
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbojson
4
+ module Ast
5
+ class Root
6
+ attr_reader :model, :table_name, :nodes
7
+
8
+ def initialize(model:, table_name:, nodes:)
9
+ @model = model
10
+ @table_name = table_name
11
+ @nodes = nodes
12
+ end
13
+ end
14
+
15
+ class Attribute
16
+ attr_reader :name, :sql
17
+
18
+ def initialize(name:, sql: nil)
19
+ @name = name
20
+ @sql = sql
21
+ end
22
+ end
23
+
24
+ class Association
25
+ attr_reader :name, :type, :serializer_class
26
+
27
+ def initialize(name:, type:, serializer_class:)
28
+ @name = name
29
+ @type = type
30
+ @serializer_class = serializer_class
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbojson
4
+ class PostProcessor
5
+ def self.process(payload, key_suffix: "_key", &block)
6
+ case payload
7
+ when Array
8
+ payload.map { |item| process(item, key_suffix: key_suffix, &block) }
9
+ when Hash
10
+ payload.each_with_object({}) do |(key, value), acc|
11
+ acc[key] = transform_value(key, value, key_suffix, &block)
12
+ end
13
+ else
14
+ payload
15
+ end
16
+ end
17
+
18
+ def self.transform_value(key, value, key_suffix, &block)
19
+ if key.to_s.end_with?(key_suffix) && block
20
+ block.call(value)
21
+ else
22
+ process(value, key_suffix: key_suffix, &block)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbojson
4
+ class SchemaInspector
5
+ def self.column?(model_class, name)
6
+ table = model_class.respond_to?(:table_name) ? model_class.table_name : nil
7
+
8
+ if model_class.respond_to?(:connection)
9
+ cache = model_class.connection.schema_cache if model_class.connection.respond_to?(:schema_cache)
10
+ if cache && cache.respond_to?(:columns_hash) && table
11
+ return cache.columns_hash(table).key?(name.to_s)
12
+ end
13
+ end
14
+
15
+ if model_class.respond_to?(:columns_hash)
16
+ return model_class.columns_hash.key?(name.to_s)
17
+ end
18
+
19
+ false
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ostruct"
5
+ require_relative "ast"
6
+ require_relative "schema_inspector"
7
+
8
+ module Turbojson
9
+ class ConfigurationError < Error; end
10
+
11
+ class Serializer
12
+ class << self
13
+ def model(klass = nil)
14
+ if klass
15
+ config[:model] = klass
16
+ else
17
+ config[:model]
18
+ end
19
+ end
20
+
21
+ def attributes(*names, &block)
22
+ raise ArgumentError, "attributes requires at least one name" if names.empty?
23
+
24
+ names.each do |name|
25
+ config[:attributes] << {
26
+ name: name.to_sym,
27
+ sql: block&.call(model_scope)
28
+ }
29
+ end
30
+ end
31
+
32
+ def has_one(name, serializer:)
33
+ config[:associations] << {
34
+ name: name.to_sym,
35
+ type: :has_one,
36
+ serializer: serializer
37
+ }
38
+ end
39
+
40
+ def has_many(name, serializer:)
41
+ config[:associations] << {
42
+ name: name.to_sym,
43
+ type: :has_many,
44
+ serializer: serializer
45
+ }
46
+ end
47
+
48
+ def ast
49
+ model_class = config[:model]
50
+ raise ConfigurationError, "Serializer missing model" unless model_class
51
+
52
+ table_name = infer_table_name(model_class)
53
+ nodes = []
54
+
55
+ config[:attributes].each do |attr|
56
+ validate_column!(model_class, attr[:name]) if attr[:sql].nil?
57
+ nodes << Ast::Attribute.new(name: attr[:name], sql: attr[:sql])
58
+ end
59
+
60
+ config[:associations].each do |assoc|
61
+ nodes << Ast::Association.new(
62
+ name: assoc[:name],
63
+ type: assoc[:type],
64
+ serializer_class: assoc[:serializer]
65
+ )
66
+ end
67
+
68
+ Ast::Root.new(model: model_class, table_name: table_name, nodes: nodes)
69
+ end
70
+
71
+ def to_sql
72
+ SqlGenerator.new(ast).to_sql
73
+ end
74
+
75
+ def serialize(scope)
76
+ raise ArgumentError, "scope is required" if scope.nil?
77
+ raise ConfigurationError, "Arel is required to build SQL" unless defined?(Arel)
78
+
79
+ generator = SqlGenerator.new(ast)
80
+ object_sql, joins = generator.select_parts
81
+
82
+ unless scope.respond_to?(:select) && scope.respond_to?(:joins) && scope.respond_to?(:to_sql)
83
+ raise ConfigurationError, "serialize expects an ActiveRecord::Relation-like scope"
84
+ end
85
+
86
+ relation = scope.select(Arel.sql("#{object_sql} AS data"))
87
+ joins.each do |join_sql|
88
+ relation = relation.joins(Arel.sql(join_sql))
89
+ end
90
+
91
+ sql = <<~SQL
92
+ SELECT COALESCE(json_agg(rows.data), '[]'::json) AS data
93
+ FROM (#{relation.to_sql}) rows
94
+ SQL
95
+
96
+ connection = scope.connection
97
+ result = connection.select_value(sql)
98
+
99
+ return result unless result.is_a?(String)
100
+
101
+ JSON.parse(result)
102
+ end
103
+
104
+ def model_scope
105
+ model_class = config[:model]
106
+ return nil unless model_class
107
+
108
+ if model_class.respond_to?(:table_name)
109
+ OpenStruct.new(table_name: model_class.table_name)
110
+ else
111
+ nil
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ def config
118
+ @config ||= begin
119
+ parent = superclass.respond_to?(:config) ? superclass.send(:config) : {}
120
+ {
121
+ model: parent[:model],
122
+ attributes: Array(parent[:attributes]).dup,
123
+ associations: Array(parent[:associations]).dup
124
+ }
125
+ end
126
+ end
127
+
128
+ def infer_table_name(model_class)
129
+ return model_class.table_name if model_class.respond_to?(:table_name)
130
+
131
+ raise ConfigurationError, "Model must respond to table_name"
132
+ end
133
+
134
+ def validate_column!(model_class, name)
135
+ unless SchemaInspector.column?(model_class, name)
136
+ raise ConfigurationError, "Unknown column #{name} for #{model_class}"
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ast"
4
+
5
+ module Turbojson
6
+ class SqlGenerator
7
+ AssociationInfo = Struct.new(:name, :table_name, :foreign_key, :parent_key)
8
+
9
+ def initialize(ast)
10
+ @ast = ast
11
+ end
12
+
13
+ def to_sql
14
+ table_name = @ast.table_name
15
+ object_sql, joins = build_json_object(@ast, table_name)
16
+
17
+ sql = []
18
+ sql << "SELECT #{object_sql} AS data"
19
+ sql << "FROM #{table_name}"
20
+ sql.concat(joins) unless joins.empty?
21
+ sql.join("\n")
22
+ end
23
+
24
+ def select_parts
25
+ build_json_object(@ast, @ast.table_name)
26
+ end
27
+
28
+ private
29
+
30
+ def build_json_object(ast, table_alias)
31
+ select_pairs = []
32
+ joins = []
33
+
34
+ ast.nodes.each do |node|
35
+ case node
36
+ when Ast::Attribute
37
+ value = node.sql || "#{table_alias}.#{node.name}"
38
+ select_pairs << "'#{node.name}', #{value}"
39
+ when Ast::Association
40
+ assoc = resolve_association(ast.model, node)
41
+ child_ast = node.serializer_class.ast
42
+ lateral_alias = "#{assoc.name}_json"
43
+
44
+ subquery = if node.type == :has_many
45
+ build_has_many_subquery(child_ast, assoc, table_alias)
46
+ else
47
+ build_has_one_subquery(child_ast, assoc, table_alias)
48
+ end
49
+
50
+ joins << "LEFT JOIN LATERAL (#{subquery}) #{lateral_alias} ON TRUE"
51
+ select_pairs << "'#{node.name}', #{lateral_alias}.#{node.name}"
52
+ end
53
+ end
54
+
55
+ ["json_build_object(#{select_pairs.join(', ')})", joins]
56
+ end
57
+
58
+ def build_has_one_subquery(child_ast, assoc, parent_table)
59
+ object_sql, child_joins = build_json_object(child_ast, assoc.table_name)
60
+ sql = []
61
+ sql << "SELECT #{object_sql} AS #{assoc.name}"
62
+ sql << "FROM #{assoc.table_name}"
63
+ sql.concat(child_joins) unless child_joins.empty?
64
+ sql << "WHERE #{assoc.table_name}.#{assoc.foreign_key} = #{parent_table}.#{assoc.parent_key}"
65
+ sql << "LIMIT 1"
66
+ sql.join("\n")
67
+ end
68
+
69
+ def build_has_many_subquery(child_ast, assoc, parent_table)
70
+ object_sql, child_joins = build_json_object(child_ast, assoc.table_name)
71
+ inner = []
72
+ inner << "SELECT #{object_sql} AS data"
73
+ inner << "FROM #{assoc.table_name}"
74
+ inner.concat(child_joins) unless child_joins.empty?
75
+ inner << "WHERE #{assoc.table_name}.#{assoc.foreign_key} = #{parent_table}.#{assoc.parent_key}"
76
+
77
+ sql = []
78
+ sql << "SELECT COALESCE(json_agg(child_rows.data), '[]'::json) AS #{assoc.name}"
79
+ sql << "FROM (#{inner.join("\n")}) child_rows"
80
+ sql.join("\n")
81
+ end
82
+
83
+ def resolve_association(model, node)
84
+ reflection = model.respond_to?(:reflect_on_association) ? model.reflect_on_association(node.name) : nil
85
+ raise ConfigurationError, "Association #{node.name} not found for #{model}" unless reflection
86
+
87
+ table_name = reflection.klass.table_name
88
+ foreign_key = reflection.foreign_key
89
+ parent_key = model.respond_to?(:primary_key) ? model.primary_key : "id"
90
+
91
+ AssociationInfo.new(node.name, table_name, foreign_key, parent_key)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Turbojson
4
+ VERSION = "0.1.0"
5
+ end
data/lib/turbojson.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "turbojson/version"
4
+ require_relative "turbojson/serializer"
5
+ require_relative "turbojson/sql_generator"
6
+ require_relative "turbojson/post_processor"
7
+
8
+ module Turbojson
9
+ class Error < StandardError; end
10
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: turbojson
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Turbojson Contributors
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: rspec
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.12'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.12'
26
+ executables: []
27
+ extensions: []
28
+ extra_rdoc_files: []
29
+ files:
30
+ - README.md
31
+ - lib/turbojson.rb
32
+ - lib/turbojson/ast.rb
33
+ - lib/turbojson/post_processor.rb
34
+ - lib/turbojson/schema_inspector.rb
35
+ - lib/turbojson/serializer.rb
36
+ - lib/turbojson/sql_generator.rb
37
+ - lib/turbojson/version.rb
38
+ licenses:
39
+ - MIT
40
+ metadata: {}
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.0'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.6.7
56
+ specification_version: 4
57
+ summary: Compile serializer DSL into Postgres JSON SQL
58
+ test_files: []