Repository: yosiat/panko_serializer Branch: master Commit: 552ee5c99e29 Files: 91 Total size: 204.4 KB Directory structure: gitextract_w1z8tyjv/ ├── .clang-format ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── database_matrix.yml │ ├── docs.yml │ ├── lint.yml │ └── tests.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Appraisals ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── benchmarks/ │ ├── object_writer.rb │ ├── panko_json.rb │ ├── panko_object.rb │ ├── plain_object.rb │ ├── support/ │ │ ├── benchmark.rb │ │ ├── datasets.rb │ │ └── setup.rb │ └── type_casts/ │ ├── generic.rb │ ├── mysql.rb │ ├── postgresql.rb │ └── sqlite.rb ├── docs/ │ ├── CNAME │ ├── Gemfile │ ├── _config.yml │ ├── associations.md │ ├── attributes.md │ ├── design-choices.md │ ├── getting-started.md │ ├── index.md │ ├── performance.md │ ├── reference.md │ └── response-bag.md ├── ext/ │ └── panko_serializer/ │ ├── attributes_writer/ │ │ ├── active_record.c │ │ ├── active_record.h │ │ ├── attributes_writer.c │ │ ├── attributes_writer.h │ │ ├── common.c │ │ ├── common.h │ │ ├── hash.c │ │ ├── hash.h │ │ ├── plain.c │ │ ├── plain.h │ │ └── type_cast/ │ │ ├── time_conversion.c │ │ ├── time_conversion.h │ │ ├── type_cast.c │ │ └── type_cast.h │ ├── common.h │ ├── extconf.rb │ ├── panko_serializer.c │ ├── panko_serializer.h │ └── serialization_descriptor/ │ ├── association.c │ ├── association.h │ ├── attribute.c │ ├── attribute.h │ ├── serialization_descriptor.c │ └── serialization_descriptor.h ├── gemfiles/ │ ├── 7.2.0.gemfile │ ├── 8.0.0.gemfile │ └── 8.1.0.gemfile ├── lib/ │ ├── panko/ │ │ ├── array_serializer.rb │ │ ├── association.rb │ │ ├── attribute.rb │ │ ├── object_writer.rb │ │ ├── response.rb │ │ ├── serialization_descriptor.rb │ │ ├── serializer.rb │ │ ├── serializer_resolver.rb │ │ └── version.rb │ └── panko_serializer.rb ├── panko_serializer.gemspec └── spec/ ├── features/ │ ├── active_record_serialization_spec.rb │ ├── array_serializer_spec.rb │ ├── associations_spec.rb │ ├── attributes_spec.rb │ ├── context_and_scope_spec.rb │ ├── filtering_spec.rb │ ├── hash_serialization_spec.rb │ └── poro_serialization_spec.rb ├── spec_helper.rb ├── support/ │ └── database_config.rb └── unit/ ├── panko/ │ ├── array_serializer_spec.rb │ ├── object_writer_spec.rb │ ├── response_spec.rb │ ├── serialization_descriptor_spec.rb │ └── serializer_spec.rb ├── serializer_resolver_spec.rb └── type_cast_spec.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .clang-format ================================================ --- Language: Cpp BasedOnStyle: Google ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "bundler" directory: "/docs" schedule: interval: "weekly" groups: docs-dependencies: patterns: - "*" - package-ecosystem: "bundler" directory: "/gemfiles" schedule: interval: "weekly" groups: gemfiles-dependencies: patterns: - "*" ================================================ FILE: .github/workflows/database_matrix.yml ================================================ name: Database Tests on: [push, pull_request] jobs: database-matrix: runs-on: ubuntu-latest strategy: fail-fast: false matrix: ruby: ["3.4", "4.0"] rails: ["7.2.0", "8.0.0"] database: ["sqlite", "postgresql", "mysql"] services: postgres: image: postgres:17 env: POSTGRES_DB: panko_test POSTGRES_USER: postgres POSTGRES_PASSWORD: password options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 mysql: image: mysql:8.0 env: MYSQL_DATABASE: panko_test MYSQL_ROOT_PASSWORD: password options: >- --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 ports: - 3306:3306 steps: - name: Install system dependencies run: | sudo apt update -y sudo apt install -y libsqlite3-dev libpq-dev libmysqlclient-dev - uses: actions/checkout@v6 - name: Configure MySQL authentication if: matrix.database == 'mysql' run: | # Configure MySQL to use mysql_native_password for Trilogy compatibility mysql -h 127.0.0.1 -P 3306 -u root -ppassword -e " ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password'; ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'password'; FLUSH PRIVILEGES; " - name: Set up Ruby ${{ matrix.ruby }} uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true working-directory: . env: BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile DB: ${{ matrix.database }} - name: Compile & test env: BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile DB: ${{ matrix.database }} POSTGRES_HOST: localhost POSTGRES_USER: postgres POSTGRES_PASSWORD: password POSTGRES_PORT: 5432 MYSQL_HOST: localhost MYSQL_USER: root MYSQL_PASSWORD: password MYSQL_PORT: 3306 run: | bundle exec rake ================================================ FILE: .github/workflows/docs.yml ================================================ name: Docs Publishing on: push: branches: [master] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: "pages" cancel-in-progress: false jobs: build: runs-on: ubuntu-latest defaults: run: working-directory: docs steps: - name: Checkout uses: actions/checkout@v6 - name: Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: "4.0" bundler-cache: true working-directory: docs - name: Setup Pages id: pages uses: actions/configure-pages@v6 - name: Build with Jekyll run: bundle exec jekyll build --baseurl "${{ steps.pages.outputs.base_path }}" env: JEKYLL_ENV: production - name: Upload artifact uses: actions/upload-pages-artifact@v4 with: path: docs/_site deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install deps run: | sudo apt update -y sudo apt install -y libsqlite3-dev - name: Lint Ruby code uses: ruby/setup-ruby@v1 with: ruby-version: 3 bundler-cache: true - run: | bundle exec rake rubocop - name: Lint C uses: jidicula/clang-format-action@v4.16.0 with: clang-format-version: "16" check-path: "ext/panko_serializer" fallback-style: "Google" ================================================ FILE: .github/workflows/tests.yml ================================================ name: Panko Serializer CI on: [push, pull_request] jobs: tests: runs-on: ubuntu-latest strategy: fail-fast: false matrix: ruby: ["3.2", "3.3", "3.4", "4.0"] rails: ["7.2.0", "8.0.0", "8.1.0"] steps: - name: Install deps run: | sudo apt update -y sudo apt install -y libsqlite3-dev - uses: actions/checkout@v6 - name: Set up Ruby ${{ matrix.ruby }} uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true working-directory: . env: BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile - name: Compile & test env: BUNDLE_GEMFILE: gemfiles/${{ matrix.rails }}.gemfile run: | bundle exec rake ================================================ FILE: .gitignore ================================================ /.bundle/ /.yardoc /Gemfile.lock /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ /vendor/bundle/ .byebug_history *.bundle .DS_Store # rspec failure tracking .rspec_status node_modules /docs/_site/ /docs/.jekyll-cache/ ================================================ FILE: .rspec ================================================ --format documentation --color ================================================ FILE: .rubocop.yml ================================================ # We want Exclude directives from different # config files to get merged, not overwritten inherit_mode: merge: - Exclude require: - standard plugins: - rubocop-performance - standard-performance - rubocop-rspec inherit_gem: standard: config/base.yml standard-performance: config/base.yml AllCops: TargetRubyVersion: 3.1 SuggestExtensions: false NewCops: disable Exclude: - ext/**/* - gemfiles/**/* Style/FrozenStringLiteralComment: Enabled: true EnforcedStyle: always SafeAutoCorrect: true # TODO: need to work on specs. RSpec: Enabled: false Lint/ConstantDefinitionInBlock: Exclude: - spec/**/* ================================================ FILE: Appraisals ================================================ # frozen_string_literal: true appraise "7.2.0" do gem "activesupport", "~> 7.2.0" gem "activemodel", "~> 7.2.0" gem "activerecord", "~> 7.2.0", group: :test gem "trilogy" gem "sqlite3", "~> 1.4" end appraise "8.0.0" do gem "activesupport", "~> 8.0.0" gem "activemodel", "~> 8.0.0" gem "activerecord", "~> 8.0.0", group: :test gem "trilogy" gem "sqlite3", ">= 2.1" end appraise "8.1.0" do gem "activesupport", "~> 8.1.0" gem "activemodel", "~> 8.1.0" gem "activerecord", "~> 8.1.0", group: :test gem "trilogy" gem "sqlite3", ">= 2.1" end ================================================ FILE: Gemfile ================================================ # frozen_string_literal: true source "https://rubygems.org" gemspec group :benchmarks do gem "vernier" gem "stackprof" gem "pg" gem "benchmark-ips" gem "memory_profiler" end group :test do gem "faker" gem "temping" end group :development do gem "byebug" gem "rake" gem "rspec", "~> 3.0" gem "rake-compiler" end group :development, :test do gem "rubocop" gem "standard" gem "standard-performance" gem "rubocop-performance" gem "rubocop-rspec" end ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2017 Yosi Attias Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Panko ![Build Status](https://github.com/yosiat/panko_serializer/workflows/Panko%20Serializer%20CI/badge.svg?branch=master) Panko is a library which is inspired by ActiveModelSerializers 0.9 for serializing ActiveRecord/Ruby objects to JSON strings, fast. To achieve its [performance](https://panko.dev/performance): * Oj - Panko relies on Oj since it's fast and allows for incremental serialization using `Oj::StringWriter` * Serialization Descriptor - Panko computes most of the metadata ahead of time, to save time later in serialization. * Type casting — Panko does type casting by itself, instead of relying on ActiveRecord. To dig deeper about the performance choices, read [Design Choices](https://panko.dev/design-choices). Support ------- - [Documentation](https://panko.dev/) - [Getting Started](https://panko.dev/getting-started) License ------- The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). ================================================ FILE: Rakefile ================================================ # frozen_string_literal: true require "bundler/gem_tasks" require "rspec/core/rake_task" require "rubocop/rake_task" require "rake/extensiontask" gem = Gem::Specification.load(File.dirname(__FILE__) + "/panko_serializer.gemspec") Rake::ExtensionTask.new("panko_serializer", gem) do |ext| ext.lib_dir = "lib/panko" end Gem::PackageTask.new(gem) do |pkg| pkg.need_zip = pkg.need_tar = false end RSpec::Core::RakeTask.new(:spec) Rake::Task[:spec].prerequisites << :compile Rake::Task[:compile].prerequisites << :clean task default: :spec RuboCop::RakeTask.new namespace :benchmarks do desc "Run all benchmarks" task :all do files = Dir["benchmarks/*.rb", "benchmarks/type_casts/*.rb"].sort files.each { |f| system("bundle", "exec", "ruby", f) || abort("FAILED: #{f}") } end desc "Run benchmarks matching NAME (e.g., rake benchmarks:run[type_casts:postgresql])" task :run, [:name] do |_, args| path = args[:name].tr(":", "/") files = Dir["benchmarks/#{path}.rb", "benchmarks/#{path}/*.rb"].sort abort "No benchmark files matching '#{args[:name]}'" if files.empty? files.each { |f| system("bundle", "exec", "ruby", f) || abort("FAILED: #{f}") } end end ================================================ FILE: benchmarks/object_writer.rb ================================================ # frozen_string_literal: true require_relative "support/benchmark" require "panko_serializer" benchmark("1 property, push_value") do writer = Panko::ObjectWriter.new writer.push_object writer.push_value "value1", "key1" writer.pop writer.output end benchmark("2 properties, push_value") do writer = Panko::ObjectWriter.new writer.push_object writer.push_value "value1", "key1" writer.push_value "value2", "key2" writer.pop writer.output end benchmark("1 property, push_key+push_value") do writer = Panko::ObjectWriter.new writer.push_object writer.push_key "key1" writer.push_value "value1" writer.pop writer.output end benchmark("2 properties, push_key+push_value") do writer = Panko::ObjectWriter.new writer.push_object writer.push_key "key1" writer.push_value "value1" writer.push_key "key2" writer.push_value "value2" writer.pop writer.output end benchmark("Nested object") do writer = Panko::ObjectWriter.new writer.push_object writer.push_value "value1", "key1" writer.push_object "key2" writer.push_value "value2", "key2" writer.pop writer.pop writer.output end ================================================ FILE: benchmarks/panko_json.rb ================================================ # frozen_string_literal: true require_relative "support/datasets" class AuthorFastSerializer < Panko::Serializer attributes :id, :name end class PostFastSerializer < Panko::Serializer attributes :id, :body, :title, :author_id, :created_at end class PostFastWithMethodCallSerializer < Panko::Serializer attributes :id, :body, :title, :author_id, :method_call def method_call object.id * 2 end end class PostFastWithJsonSerializer < Panko::Serializer attributes :id, :body, :title, :author_id, :created_at, :data end class PostWithHasOneFastSerializer < Panko::Serializer attributes :id, :body, :title, :author_id, :created_at has_one :author, serializer: AuthorFastSerializer end class AuthorWithHasManyFastSerializer < Panko::Serializer attributes :id, :name has_many :posts, serializer: PostFastSerializer end class PostWithAliasFastSerializer < Panko::Serializer attributes :new_id, :new_body, :new_title, :new_author_id, :new_created_at end benchmark_with_records("Simple", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostFastSerializer).to_json } benchmark_with_records("HasOne", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostWithHasOneFastSerializer).to_json } benchmark_with_records("HasMany", type: :authors) { |r| Panko::ArraySerializer.new(r, each_serializer: AuthorWithHasManyFastSerializer).to_json } benchmark_with_records("MethodCall", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostFastWithMethodCallSerializer).to_json } benchmark_with_records("JSON column", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostFastWithJsonSerializer).to_json } benchmark_with_records("Except", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostWithHasOneFastSerializer, except: [:title]).to_json } benchmark_with_records("Only", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostWithHasOneFastSerializer, only: [:id, :body, :author_id, :author]).to_json } benchmark_with_records("Aliases", type: :aliased_posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostWithAliasFastSerializer).to_json } ================================================ FILE: benchmarks/panko_object.rb ================================================ # frozen_string_literal: true require_relative "support/datasets" class AuthorFastSerializer < Panko::Serializer attributes :id, :name end class PostFastSerializer < Panko::Serializer attributes :id, :body, :title, :author_id, :created_at end class PostWithHasOneFastSerializer < Panko::Serializer attributes :id, :body, :title, :author_id, :created_at has_one :author, serializer: AuthorFastSerializer end class PostWithAliasFastSerializer < Panko::Serializer attributes :new_id, :new_body, :new_title, :new_author_id, :new_created_at end benchmark_with_records("Simple", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostFastSerializer).to_a } benchmark_with_records("HasOne", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostWithHasOneFastSerializer).to_a } benchmark_with_records("Except", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostWithHasOneFastSerializer, except: [:title]).to_a } benchmark_with_records("Only", type: :posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostWithHasOneFastSerializer, only: [:id, :body, :author_id, :author]).to_a } benchmark_with_records("Aliases", type: :aliased_posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PostWithAliasFastSerializer).to_a } ================================================ FILE: benchmarks/plain_object.rb ================================================ # frozen_string_literal: true require_relative "support/datasets" class PlainAuthorSerializer < Panko::Serializer attributes :id, :name end class PlainPostSerializer < Panko::Serializer attributes :id, :body, :title, :author_id, :created_at end class PlainPostWithMethodCallSerializer < Panko::Serializer attributes :id, :body, :title, :author_id, :method_call def method_call object.id * 2 end end class PlainPostWithHasOneSerializer < Panko::Serializer attributes :id, :body, :title, :author_id, :created_at has_one :author, serializer: PlainAuthorSerializer end benchmark_with_records("Simple", type: :plain_posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PlainPostSerializer).to_json } benchmark_with_records("HasOne", type: :plain_posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PlainPostWithHasOneSerializer).to_json } benchmark_with_records("MethodCall", type: :plain_posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PlainPostWithMethodCallSerializer).to_json } benchmark_with_records("Except", type: :plain_posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PlainPostWithHasOneSerializer, except: [:title]).to_json } benchmark_with_records("Only", type: :plain_posts) { |r| Panko::ArraySerializer.new(r, each_serializer: PlainPostWithHasOneSerializer, only: [:id, :body, :author_id, :author]).to_json } ================================================ FILE: benchmarks/support/benchmark.rb ================================================ # frozen_string_literal: true require "bundler/setup" require "benchmark/ips" require "memory_profiler" require "active_support/all" # Enable YJIT if available (Ruby 3.1+) RubyVM::YJIT.enable if defined?(RubyVM::YJIT.enable) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- # Sizes to benchmark. Override with SIZE=n env var (single run). # @return [Array] BENCHMARK_SIZES = ENV["SIZE"] ? [Integer(ENV["SIZE"])] : [50, 2300] # Registry of pre-loaded dataset slices, keyed by type symbol. # Populated by support/datasets.rb before any benchmark file runs. # @return [Hash{Symbol => Hash}] DATASETS = {} # Benchmark.ips measurement time in seconds (default 10). # @return [Integer] IPS_TIME = Integer(ENV.fetch("IPS_TIME", 10)) # Benchmark.ips warmup time in seconds (default 3). # @return [Integer] IPS_WARMUP = Integer(ENV.fetch("IPS_WARMUP", 3)) # --------------------------------------------------------------------------- # NoopWriter # --------------------------------------------------------------------------- # A no-op Oj::StringWriter stand-in used by benchmarks that test # serialization logic without paying JSON-string allocation costs. class NoopWriter # The last value passed to push_value. # @return [Object, nil] attr_reader :value # Records +value+ without writing JSON. # # @param value [Object] the value to (not) write # @param key [String, nil] ignored # @return [void] def push_value(value, key = nil) @value = value end # No-op JSON push. # # @param value [String] ignored # @param key [String, nil] ignored # @return [nil] def push_json(value, key = nil) # rubocop:disable Lint/UnusedMethodArgument nil end end # --------------------------------------------------------------------------- # Internal state # --------------------------------------------------------------------------- # @!visibility private @header_printed = false # @!visibility private @profile_blocks = [] # --------------------------------------------------------------------------- # print_header # --------------------------------------------------------------------------- # Prints the benchmark table header once, deriving the section title from # the calling file's basename (without extension). # # @return [void] def print_header return if @header_printed @header_printed = true title = File.basename($PROGRAM_NAME, ".*") width = 78 puts "=" * width puts " #{title}".center(width) puts "=" * width puts "benchmark ips allocs retained" puts "-" * width end # --------------------------------------------------------------------------- # benchmark # --------------------------------------------------------------------------- # Runs a single benchmark case, printing one formatted result row. # # Respects the BENCH env var: if set, only runs benchmarks whose +label+ # contains the value as a case-insensitive substring. # # When PROFILE=cpu : collects the block for a single StackProf run at exit. # When PROFILE=memory: runs MemoryProfiler and calls pretty_print immediately. # Normal mode : disables GC, measures allocations + ips, prints a row. # # @param label [String] human-readable name shown in the output table # @yield the code under measurement (called many times by Benchmark.ips) # @return [void] def benchmark(label, &block) filter = ENV["BENCH"] return if filter && !label.downcase.include?(filter.downcase) print_header case ENV["PROFILE"] when "cpu" @profile_blocks << [label, block] return when "memory" report = MemoryProfiler.report(&block) report.pretty_print return end GC.start GC.disable memory_report = MemoryProfiler.report(&block) ips_result = Benchmark.ips(IPS_TIME, IPS_WARMUP, true) do |x| x.report(label, &block) end GC.enable ips = ips_result.entries.first.ips.round(2) allocs = memory_report.total_allocated retained = memory_report.total_retained ips_str = format("%.2f", ips).reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse allocs_str = allocs.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse retained_str = retained.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse puts format("%-54s %12s %10s %10s", label, ips_str, allocs_str, retained_str) end # --------------------------------------------------------------------------- # benchmark_with_records # --------------------------------------------------------------------------- # Iterates BENCHMARK_SIZES and runs one benchmark per size, automatically # slicing the dataset registered under +type+. # # @param label [String] benchmark label prefix (size + noun are appended) # @param type [Symbol] key into DATASETS (e.g. :posts) # @yield [records] the subset of records for this size # @yieldparam records [Array] first +n+ records from the dataset # @return [void] def benchmark_with_records(label, type:, &block) dataset = DATASETS.fetch(type) data = dataset[:data] noun = dataset[:noun] BENCHMARK_SIZES.each do |n| subset = data.first(n) benchmark("#{label}, #{n} #{noun}") { block.call(subset) } end end # --------------------------------------------------------------------------- # run_cpu_profile # --------------------------------------------------------------------------- # Runs all blocks collected in PROFILE=cpu mode under a single StackProf # session and prints the results. # # @return [void] def run_cpu_profile return if @profile_blocks.empty? require "stackprof" combined = @profile_blocks.map { |_lbl, blk| blk } profile = StackProf.run(mode: :cpu, raw: true) do combined.each(&:call) end StackProf::Report.new(profile).print_text end # --------------------------------------------------------------------------- # at_exit # --------------------------------------------------------------------------- at_exit do run_cpu_profile if ENV["PROFILE"] == "cpu" puts "=" * 78 if @header_printed end ================================================ FILE: benchmarks/support/datasets.rb ================================================ # frozen_string_literal: true require_relative "benchmark" require_relative "setup" require "panko_serializer" # --- AR datasets --- DATASETS[:posts] = {data: Post.all.includes(:author).to_a, noun: "posts"} DATASETS[:authors] = {data: Author.all.includes(:posts).to_a, noun: "authors"} DATASETS[:aliased_posts] = {data: PostWithAliasModel.all.to_a, noun: "aliased posts"} # --- Plain Ruby datasets (no AR dependency) --- class PlainAuthor attr_accessor :id, :name end class PlainPost attr_accessor :id, :body, :title, :created_at, :author_id attr_reader :author def author=(author) @author = author @author_id = author.id end end plain_posts = 2300.times.map do |i| author = PlainAuthor.new author.id = i author.name = "Author #{i}" post = PlainPost.new post.id = i post.body = "something about how password restrictions are evil" post.title = "Your bank does not know how to do security" post.created_at = Time.now post.author = author post end DATASETS[:plain_posts] = {data: plain_posts, noun: "plain posts"} ================================================ FILE: benchmarks/support/setup.rb ================================================ # frozen_string_literal: true require "active_record" require "sqlite3" require "securerandom" # Change the following to reflect your database settings ActiveRecord::Base.establish_connection( adapter: "sqlite3", database: ":memory:" ) # Don't show migration output when constructing fake db ActiveRecord::Migration.verbose = false ActiveRecord::Schema.define do create_table :authors, force: true do |t| t.string :name t.timestamps(null: false) end create_table :posts, force: true do |t| t.text :body t.string :title t.references :author t.json :data t.timestamps(null: false) end end class Author < ActiveRecord::Base has_many :posts end class Post < ActiveRecord::Base belongs_to :author end class PostWithAliasModel < ActiveRecord::Base self.table_name = "posts" alias_attribute :new_id, :id alias_attribute :new_body, :body alias_attribute :new_title, :title alias_attribute :new_author_id, :author_id alias_attribute :new_created_at, :created_at end Post.transaction do 2300.times do Post.create( body: SecureRandom.hex(30), title: SecureRandom.hex(20), author: Author.create(name: SecureRandom.alphanumeric), data: {a: 1, b: 2, c: 3} ) end end ================================================ FILE: benchmarks/type_casts/generic.rb ================================================ # frozen_string_literal: true require_relative "../support/benchmark" require "active_record" require "panko_serializer" Time.zone = "UTC" def bench_type(type_klass, from, to, label: type_klass.name) converter = type_klass.new benchmark("#{label} TypeCast") do Panko._type_cast(converter, from) end benchmark("#{label} NoTypeCast") do Panko._type_cast(converter, to) end end bench_type ActiveRecord::Type::String, 1, "1" bench_type ActiveRecord::Type::Text, 1, "1" bench_type ActiveRecord::Type::ImmutableString, 1, "1" bench_type ActiveRecord::Type::Integer, "1", 1 bench_type ActiveRecord::Type::BigInteger, "1", 1 bench_type ActiveRecord::Type::Float, "1.23", 1.23 bench_type ActiveRecord::Type::Decimal, "123.45", BigDecimal("123.45") bench_type ActiveRecord::Type::Boolean, "true", true bench_type ActiveRecord::Type::Boolean, "t", true, label: "ActiveRecord::Type::Boolean(t)" bench_type ActiveRecord::Type::Date, "2017-03-04", Date.new(2017, 3, 4) bench_type ActiveRecord::Type::Time, "2000-01-01 12:45:23", Time.utc(2000, 1, 1, 12, 45, 23) bench_type ActiveRecord::Type::DateTime, "2017-03-04 12:45:23", Time.utc(2017, 3, 4, 12, 45, 23) bench_type ActiveRecord::Type::Binary, "data", "data".b if defined?(ActiveRecord::Type::Json) bench_type ActiveRecord::Type::Json, '{"a":1}', {"a" => 1} end ================================================ FILE: benchmarks/type_casts/mysql.rb ================================================ # frozen_string_literal: true require_relative "../support/benchmark" require "active_record" require "panko_serializer" begin require "active_record/connection_adapters/mysql2_adapter" rescue LoadError begin require "active_record/connection_adapters/trilogy_adapter" rescue LoadError puts "Skipping MySQL type_casts: mysql2/trilogy gem not installed" exit 0 end end def bench_type(type_klass, from, to, label: type_klass.name) converter = type_klass.new benchmark("#{label} TypeCast") do Panko._type_cast(converter, from) end benchmark("#{label} NoTypeCast") do Panko._type_cast(converter, to) end end def bench_type_with_instance(instance, from, to, label:) benchmark("#{label} TypeCast") do Panko._type_cast(instance, from) end benchmark("#{label} NoTypeCast") do Panko._type_cast(instance, to) end end # --- MySQL-specific: UnsignedInteger --- if defined?(ActiveRecord::Type::UnsignedInteger) bench_type ActiveRecord::Type::UnsignedInteger, "42", 42 end # --- MySQL String with boolean coercion (true: "1", false: "0") --- mysql_string = ActiveModel::Type::String.new(true: "1", false: "0") # rubocop:disable Lint/BooleanSymbol bench_type_with_instance(mysql_string, 1, "1", label: "MySQL::String (bool coercion)") mysql_immutable = ActiveModel::Type::ImmutableString.new(true: "1", false: "0") # rubocop:disable Lint/BooleanSymbol bench_type_with_instance(mysql_immutable, 1, "1", label: "MySQL::ImmutableString (bool coercion)") # --- MySQL Text size variants --- bench_type_with_instance(ActiveRecord::Type::Text.new(limit: 2**8 - 1), 1, "1", label: "MySQL::TinyText") bench_type_with_instance(ActiveRecord::Type::Text.new(limit: 2**16 - 1), 1, "1", label: "MySQL::Text") bench_type_with_instance(ActiveRecord::Type::Text.new(limit: 2**24 - 1), 1, "1", label: "MySQL::MediumText") bench_type_with_instance(ActiveRecord::Type::Text.new(limit: 2**32 - 1), 1, "1", label: "MySQL::LongText") # --- MySQL Binary size variants --- bench_type_with_instance(ActiveModel::Type::Binary.new(limit: 2**8 - 1), "data", "data".b, label: "MySQL::TinyBlob") bench_type_with_instance(ActiveModel::Type::Binary.new(limit: 2**16 - 1), "data", "data".b, label: "MySQL::Blob") bench_type_with_instance(ActiveModel::Type::Binary.new(limit: 2**24 - 1), "data", "data".b, label: "MySQL::MediumBlob") bench_type_with_instance(ActiveModel::Type::Binary.new(limit: 2**32 - 1), "data", "data".b, label: "MySQL::LongBlob") # --- MySQL Float variants --- bench_type_with_instance(ActiveModel::Type::Float.new(limit: 24), "1.23", 1.23, label: "MySQL::Float (single)") bench_type_with_instance(ActiveModel::Type::Float.new(limit: 53), "1.23", 1.23, label: "MySQL::Double (double)") # --- MySQL Boolean (via tinyint(1) emulation) --- bench_type_with_instance(ActiveModel::Type::Boolean.new, 1, true, label: "MySQL::Boolean (tinyint)") ================================================ FILE: benchmarks/type_casts/postgresql.rb ================================================ # frozen_string_literal: true require_relative "../support/benchmark" require "active_record" require "panko_serializer" begin require "pg" require "active_record/connection_adapters/postgresql_adapter" rescue LoadError puts "Skipping PostgreSQL type_casts: pg gem not installed" exit 0 end Time.zone = "UTC" def bench_type(type_klass, from, to, label: type_klass.name) converter = type_klass.new benchmark("#{label} TypeCast") do Panko._type_cast(converter, from) end benchmark("#{label} NoTypeCast") do Panko._type_cast(converter, to) end end PG_OID = ActiveRecord::ConnectionAdapters::PostgreSQL::OID bench_type PG_OID::Uuid, "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11" if defined?(PG_OID::Jsonb) bench_type PG_OID::Jsonb, '{"a":1}', {"a" => 1} end if defined?(PG_OID::Hstore) bench_type PG_OID::Hstore, '"a"=>"1"', {"a" => "1"} end if defined?(PG_OID::Inet) bench_type PG_OID::Inet, "192.168.1.1", IPAddr.new("192.168.1.1") end if defined?(PG_OID::Cidr) bench_type PG_OID::Cidr, "192.168.1.0/24", IPAddr.new("192.168.1.0/24") end if defined?(PG_OID::Macaddr) bench_type PG_OID::Macaddr, "00:11:22:33:44:55", "00:11:22:33:44:55" end if defined?(PG_OID::Point) bench_type PG_OID::Point, "(1.0,2.0)", [1.0, 2.0] end if defined?(PG_OID::Money) bench_type PG_OID::Money, "$1,234.56", BigDecimal("1234.56") end if defined?(PG_OID::Bit) bench_type PG_OID::Bit, "101", "101" end if defined?(PG_OID::BitVarying) bench_type PG_OID::BitVarying, "101", "101" end if defined?(PG_OID::Xml) bench_type PG_OID::Xml, "", "" end if defined?(PG_OID::Enum) bench_type PG_OID::Enum, "active", "active" end if defined?(PG_OID::DateTime) tz_type = PG_OID::DateTime.new tz_converter = ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter.new(tz_type) benchmark("PG DateTime+TZ TypeCast") do Panko._type_cast(tz_converter, "2017-07-10 09:26:40.937392") end end ================================================ FILE: benchmarks/type_casts/sqlite.rb ================================================ # frozen_string_literal: true require_relative "../support/benchmark" require "active_record" require "sqlite3" require "panko_serializer" ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") sqlite_int_type = ActiveRecord::Type::Integer.new(limit: 8) benchmark("SQLite3 Integer(limit:8) TypeCast") do Panko._type_cast(sqlite_int_type, "42") end benchmark("SQLite3 Integer(limit:8) NoTypeCast") do Panko._type_cast(sqlite_int_type, 42) end ================================================ FILE: docs/CNAME ================================================ panko.dev ================================================ FILE: docs/Gemfile ================================================ # frozen_string_literal: true source "https://rubygems.org" gem "just-the-docs" ================================================ FILE: docs/_config.yml ================================================ title: Panko Serializers description: High Performance JSON Serialization for ActiveRecord & Ruby Objects url: https://panko.dev baseurl: "" theme: just-the-docs enable_copy_code_button: true nav_external_links: - title: GitHub url: https://github.com/yosiat/panko_serializer footer_content: >-
Copyright © 2026 Panko Serializer. Distributed under the
MIT License. exclude: - Gemfile - Gemfile.lock ================================================ FILE: docs/associations.md ================================================ --- title: Associations layout: default nav_order: 6 parent: Reference --- # Associations A serializer can define it's own associations - both `has_many` and `has_one` to serialize under the context of the object. For example: ```ruby class PostSerializer < Panko::Serializer attributes :title, :body has_one :author, serializer: AuthorSerializer has_many :comments, each_serializer: CommentSerializer end ``` ### Associations with aliases An association key name can be aliased with the `name` option. For example: the `actual_author` property will be converted to `alias_author`. ```ruby class PostSerializer < Panko::Serializer attributes :title, :body has_one :actual_author, serializer: AuthorSerializer, name: :alias_author has_many :comments, each_serializer: CommentSerializer end ``` ### Inference Panko can find the type of the serializer by looking at the relationship name, so instead of specifying the serializer at the above example, we can: ```ruby class PostSerializer < Panko::Serializer attributes :title, :body has_one :author has_many :comments end ``` The logic of inferencing is: - Take the name of the relationship (for example - `:author` / `:comments`) singularize and camelize it. - Look for const defined with the name above and "Serializer" suffix (by using `Object.const_get`). > If Panko can't find the serializer it will throw an error on startup time, for example: `Can't find serializer for PostSerializer.author has_one relationship`. ## Nested Filters As talked before, Panko allows you to filter the attributes of a serializer. But Panko lets you take that step further, and filters the attributes of you associations so you can re-use your serializers in your application. For example, let's say one portion of the application needs to serialize a list of posts but only with their - `title`, `body`, author's id and comments id. We can declare tailored serializer for this, or we can re-use the above defined serializer - `PostSerializer` and use nested filters. ```ruby posts = Post.all Panko::ArraySerializer.new(posts, each_serializer: PostSerializer, only: { instance: [:title, :body, :author, :comments], author: [:id], comments: [:id], }) ``` Let's dissect the `only` option we passed: - `instance` - list of attributes (and associations) we want to serialize for the current instance of the serializer, in this case - `PostSerializer`. - `author`, `comments` - here we specify the list of attributes we want to serialize for each association. It's important to note that Nested Filters are recursive, in other words, we can filter the association's associations. For example, `CommentSerializer` has an `has_one` association `Author`, and for each `comments.author` we can only serialize it's name. ```ruby posts = Post.all Panko::ArraySerializer.new(posts, only: { instance: [:title, :body, :author, :comments], author: [:id], comments: { instance: [:id, :author], author: [:name] } }) ``` As you see now in `comments` the `instance` have different meaning, the `CommentSerializer`. ================================================ FILE: docs/attributes.md ================================================ --- title: Attributes layout: default nav_order: 5 parent: Reference --- # Attributes Attributes allow you to specify which record attributes you want to serialize. There are two types of attributes: - Field - simple columns defined on the record it self. - Virtual/Method - this allows to include properties beyond simple fields. ```ruby class UserSerializer < Panko::Serializer attributes :full_name def full_name "#{object.first_name} #{object.last_name}" end end ``` ## Field Attributes Using field attributes you can control which columns of the given ActiveRecord object you want to serialize. Instead of relying on ActiveRecord to do it's type casting, Panko does on it's own for performance reasons (read more in [Design Choices]({% link design-choices.md %}#type-casting)). ## Method Attributes Method attributes are used when your serialized values can be derived from the object you are serializing. The serializer's attribute methods can access the object being serialized as `object`: ```ruby class PostSerializer < Panko::Serializer attributes :author_name def author_name "#{object.author.first_name} #{object.author.last_name}" end end ``` Another useful thing you can pass your serializer is `context`, a `context` is a bag of data whom your serializer may need. For example, here we will pass feature flags: ```ruby class UserSerializer < Panko::Serializer attributes :id, :email def feature_flags context[:feature_flags] end end serializer = UserSerializer.new(context: { feature_flags: FeatureFlags.all }) serializer.serialize(User.first) ``` ## Filters Filters allows us to reduce the amount of attributes we can serialize, therefore reduce the data usage & performance of serializing. There are two types of filters: - only - use those attributes **only** and nothing else. - except - all attributes **except** those attributes. Usage example: ```ruby class UserSerializer < Panko::Serializer attributes :id, :name, :email end # this line will return { 'name': '..' } UserSerializer.new(only: [:name]).serialize(User.first) # this line will return { 'id': '..', 'email': ... } UserSerializer.new(except: [:name]).serialize(User.first) ``` > **Note** that if you want to user filter on an associations, the `:name` property is not taken into account. If you have a `has_many :state_transitions, name: :history` association defined, the key to use in filters is `:state_transitions` (e.g. `{ except: [:state_transitions] }`). ## Filters For Sometimes you find yourself having the same filtering logic in actions. In order to solve this duplication, Panko allows you to write the filters in the serializer. ```ruby class UserSerializer < Panko::Serializer attributes :id, :name, :email def self.filters_for(context, scope) { only: [:name] } end end # this line will return { 'name': '..' } UserSerializer.serialize(User.first) ``` > See discussion in: [https://github.com/yosiat/panko_serializer/issues/16](https://github.com/yosiat/panko_serializer/issues/16) ## Aliases Let's say we have an attribute name that we want to expose to client as different name, the current way of doing so is using method attribute, for example: ```ruby class PostSerializer < Panko::Serializer attributes :published_at def published_at object.created_at end end ``` The downside of this approach is that `created_at` skips Panko's type casting, therefore we get a direct hit on performance. To fix this, we can use aliases: ```ruby class PostSerializer < Panko::Serializer aliases created_at: :published_at end ``` ================================================ FILE: docs/design-choices.md ================================================ --- title: Design Choices layout: default nav_order: 4 --- # Design Choices In short, Panko is a serializer for ActiveRecord objects (it can't serialize any other object), which strives for high performance & simple API (which is inspired by ActiveModelSerializers). Its performance is achieved by: - `Oj::StringWriter` - I will elaborate later. - Type casting — instead of relying on ActiveRecord to do its type cast, Panko is doing it by itself. - Figuring out the metadata, ahead of time — therefore, we ask less questions during the `serialization loop`. ## Serialization overview First, let's start with an overview. Let's say we want to serialize an `User` object, which has `first_name`, `last_name`, `age`, and `email` properties. The serializer definition will be something like this: ```ruby class UserSerializer < Panko::Serializer attributes :name, :age, :email def name "#{object.first_name} #{object.last_name}" end end ``` And the usage of this serializer will be: ```ruby # fetch user from database user = User.first # create serializer, with empty options serializer = UserSerializer.new # serialize to JSON serializer.serialize_to_json(user) ``` Let's go over the steps that Panko will execute behind the scenes for this flow. _I will skip the serializer definition part, because it's fairly simple and straightforward (see `lib/panko/serializer.rb`)._ First step, while initializing the UserSerializer, we will create a **Serialization Descriptor** for this class. Serialization Descriptor's goal is to answer those questions: - Which fields do we have? In our case, `:age`, `:email`. - Which method fields do we have? In our case `:name`. - Which associations do we have (and their serialization descriptors)? The serialization description is also responsible for filtering the attributes (`only` \\ `except`). Now, that we have the serialization descriptor, we are finished with the Ruby part of Panko, and all we did here is done in _initialization time_ and now we move to C code. In C land, we take the `user` object and the serialization descriptor, and start the serialization process which is separated to 4 parts: - Serializing Fields - looping through serialization descriptor's `fields` and read them from the ActiveRecord object (see `Type Casting`) and write them to the writer. - Serializing Method Fields - creating (a cached) serializer instance, setting its `@object` and `@context`, calling all the method fields and writing them to the writer. - Serializing associations — this is simple, once we have fields + method fields, we just repeat the process. Once this is finished, we have a nice JSON string. Now let's dig deeper. ## Interesting parts ### Oj::StringWriter If you read the code of ActiveRecord serialization code in Ruby, you will observe this flow: 1. Get an array of ActiveRecord objects (`User.all` for example). 2. Build a new array of hashes where each hash is an `User` with the attributes we selected. 3. The JSON serializer, takes this array of hashes and loop them, and converts it to a JSON string. This entire process is expensive in terms of Memory & CPU, and this where the combination of Panko and Oj::StringWriter really shines. In Panko, the serialization process of the above is: 1. Get an array of ActiveRecord objects (`User.all` for example). 2. Create `Oj::StringWriter` and feed the values to it, via `push_value` / `push_object` / `push_object` and behind the scene, `Oj::StringWriter` will serialize the objects incrementally into a string. 3. Get from `Oj::StringWriter` the completed JSON string — which is a no-op, since `Oj::StringWriter` already built the string. ### Figuring out the metadata, ahead of time. Another observation I noticed in the Ruby serializers is that they ask and do a lot in a serialization loop: - Is this field a method? is it a property? - Which fields and associations do I need for the serializer to consider the `only` and `except` options? - What is the serializer of this has_one association? Panko tries to ask the bare minimum in serialization by building `Serialization Descriptor` for each serialization and caching it. The Serialization Descriptor will do the filtering of `only` and `except` and will check if a field is a method or not (therefore Panko doesn't have list of `attributes`). ### Type Casting This is the final part, which helped yield most of the performance improvements. In ActiveRecord, when we read the value of an attribute, it does type casting of the DB value to its real Ruby type. For example, time strings are converted to Time objects, Strings are duplicated, and Integers are converted from their values to Number. This type casting is really expensive, as it's responsible for most of the allocations in the serialization flow and most of them can be "relaxed". If we think about it, we don't need to duplicate strings or convert time strings to time objects or even parse JSON strings for the JSON serialization process. What Panko does is that if we have ActiveRecord type string, we won't duplicate it. If we have an integer string value, we will convert it to an integer, and the same goes for other types. All of these conversions are done in C, which of course yields a big performance improvement. #### Time type casting While you read Panko source code, you will encounter the time type casting and immediately you will have a "WTF?" moment. The idea behind the time type casting code relies on the end result of JSON type casting — what we need in order to serialize Time to JSON? UTC ISO8601 time format representation. The time type casting works as follows: - If it's a string that ends with `Z`, and the strings matches the UTC ISO8601 regex, then we just return the string. - If it's a string and it doesn't follow the rules above, we check if it's a timestamp in database format and convert it via regex + string concat to UTC ISO8601 - Yes, there is huge assumption here, that the database returns UTC timestamps — this will be configurable (before Panko official release). - If it's none of the above, I will let ActiveRecord type casting do it's magic. ================================================ FILE: docs/getting-started.md ================================================ --- title: Getting Started layout: default nav_order: 2 --- # Getting Started ## Installation To install Panko, all you need is to add it to your Gemfile: ```ruby gem "panko_serializer" ``` Then, install it on the command line: ``` bundle install ``` ## Creating your first serializer Let's create a serializer and use it inside of a Rails controller: ```ruby class PostSerializer < Panko::Serializer attributes :title end class UserSerializer < Panko::Serializer attributes :id, :name, :age has_many :posts, serializer: PostSerializer end ``` ### Serializing an object And now serialize a single object: ```ruby # Using Oj serializer PostSerializer.new.serialize_to_json(Post.first) # or, similar to #serializable_hash PostSerializer.new.serialize(Post.first).to_json ``` ### Using the serializers in a controller As you can see, defining serializers is simple and resembles ActiveModelSerializers 0.9. To utilize the `UserSerializer` inside a Rails controller and serialize some users, all we need to do is: ```ruby class UsersController < ApplicationController def index users = User.includes(:posts).all render json: Panko::ArraySerializer.new(users, each_serializer: UserSerializer).to_json end end ``` And voila, we have an endpoint which serializes users using Panko! ================================================ FILE: docs/index.md ================================================ --- title: Introduction layout: default nav_order: 1 --- # Introduction Panko is a library which is inspired by ActiveModelSerializers 0.9 for serializing ActiveRecord/Ruby objects to JSON strings, fast. To achieve it's [performance]({% link performance.md %}): - Oj - Panko relies on Oj since it's fast and allow to serialize incrementally using `Oj::StringWriter`. - Serialization Descriptor - Panko computes most of the metadata ahead of time, to save time later in serialization. - Type casting — Panko does type casting by itself, instead of relying on ActiveRecord. ================================================ FILE: docs/performance.md ================================================ --- title: Performance layout: default nav_order: 3 --- # Performance The performance of Panko is measured using microbenchmarks and load testing. ## Microbenchmarks The following microbenchmarks are run on MacBook Pro (16-inch, 2021, M1 Max), Ruby 3.2.0 with Rails 7.0.5 demonstrating the performance of ActiveModelSerializers 0.10.13 and Panko 0.8.0. | Benchmark | AMS ip/s | Panko ip/s | | ----------------- | -------- | ---------- | | Simple_Posts_2300 | 11.72 | 523.05 | | Simple_Posts_50 | 557.29 | 23,011.9 | | HasOne_Posts_2300 | 5.91 | 233.44 | | HasOne_Posts_50 | 285.8 | 10,362.79 | ## Real-world benchmark The real-world benchmark here is an endpoint which serializes 7,884 entries with 48 attributes and no associations. The benchmark took place in an environment that simulates production environment and run using `wrk` from machine on the same cluster. | Metric | AMS | Panko | | ------------------ | ----- | ----- | | Avg Response Time | 4.89s | 1.48s | | Max Response Time | 5.42s | 1.83s | | 99th Response Time | 5.42s | 1.74s | | Total Requests | 61 | 202 | _Thanks to [Bringg](https://www.bringg.com) for providing the infrastructure for the benchmarks._ ================================================ FILE: docs/reference.md ================================================ --- title: Reference layout: default nav_order: 5 has_children: true --- # Reference Detailed reference documentation for Panko Serializer's features. ================================================ FILE: docs/response-bag.md ================================================ --- title: Response layout: default nav_order: 7 parent: Reference --- # Response Let's say you have some JSON payload which is constructed using Panko serialization result, like this: ```ruby class PostsController < ApplicationController def index posts = Post.all render json: { success: true, total_count: posts.count, posts: Panko::ArraySerializer.new(posts, each_serializer: PostSerializer).to_json } end end ``` The output of the above will be a JSON string (for `posts`) inside a JSON string and this were `Panko::Response` shines. ```ruby class PostsController < ApplicationController def index posts = Post.all render json: Panko::Response.new( success: true, total_count: posts.count, posts: Panko::ArraySerializer.new(posts, each_serializer: PostSerializer) ) end end ``` And everything will work as expected! For a single object serialization, we need to use a different API (since `Panko::Serializer` doesn't accept an object in it's constructor): ```ruby class PostsController < ApplicationController def show post = Post.find(params[:id]) render( json: Panko::Response.create do |r| { success: true, post: r.serializer(post, PostSerializer) } end ) end end ``` ## JsonValue Let's take the above example further, we will serialize the posts and cache it as JSON string in our Cache. Now, you can wrap the cached value with `Panko::JsonValue`, like here: ```ruby class PostsController < ApplicationController def index posts = Cache.get("/posts") render json: Panko::Response.new( success: true, total_count: posts.count, posts: Panko::JsonValue.from(posts) ) end end ``` ================================================ FILE: ext/panko_serializer/attributes_writer/active_record.c ================================================ #include "active_record.h" static ID attributes_id; static ID types_id; static ID additional_types_id; static ID values_id; static ID delegate_hash_id; static ID value_before_type_cast_id; static ID type_id; static ID fetch_id; struct attributes { // Hash VALUE attributes_hash; size_t attributes_hash_size; // Hash VALUE types; // Hash VALUE additional_types; // heuristics bool tryToReadFromAdditionalTypes; // Rails <8: Hash // Rails >=8: ActiveRecord::Result::IndexedRow VALUE values; // Hash VALUE indexed_row_column_indexes; // Array or NIL VALUE indexed_row_row; bool is_indexed_row; }; struct attributes init_context(VALUE obj) { volatile VALUE attributes_set = rb_ivar_get(obj, attributes_id); volatile VALUE attributes_hash = rb_ivar_get(attributes_set, attributes_id); struct attributes attrs = (struct attributes){ .attributes_hash = PANKO_EMPTY_HASH(attributes_hash) ? Qnil : attributes_hash, .attributes_hash_size = 0, .types = rb_ivar_get(attributes_set, types_id), .additional_types = rb_ivar_get(attributes_set, additional_types_id), .tryToReadFromAdditionalTypes = PANKO_EMPTY_HASH(rb_ivar_get(attributes_set, additional_types_id)) == false, .values = rb_ivar_get(attributes_set, values_id), .is_indexed_row = false, .indexed_row_column_indexes = Qnil, .indexed_row_row = Qnil, }; if (attrs.attributes_hash != Qnil) { attrs.attributes_hash_size = RHASH_SIZE(attrs.attributes_hash); } if (strcmp(rb_class2name(CLASS_OF(attrs.values)), "ActiveRecord::Result::IndexedRow") == 0) { volatile VALUE indexed_row_column_indexes = rb_ivar_get(attrs.values, rb_intern("@column_indexes")); volatile VALUE indexed_row_row = rb_ivar_get(attrs.values, rb_intern("@row")); attrs.indexed_row_column_indexes = indexed_row_column_indexes; attrs.indexed_row_row = indexed_row_row; attrs.is_indexed_row = true; } return attrs; } VALUE _read_value_from_indexed_row(struct attributes attributes_ctx, volatile VALUE member) { volatile VALUE value = Qnil; if (NIL_P(attributes_ctx.indexed_row_column_indexes) || NIL_P(attributes_ctx.indexed_row_row)) { return value; } volatile VALUE column_index = rb_hash_aref(attributes_ctx.indexed_row_column_indexes, member); if (NIL_P(column_index)) { return value; } volatile VALUE row = attributes_ctx.indexed_row_row; if (NIL_P(row)) { return value; } return RARRAY_AREF(row, NUM2INT(column_index)); } VALUE read_attribute(struct attributes attributes_ctx, Attribute attribute, volatile VALUE* isJson) { volatile VALUE member, value; member = attribute->name_str; value = Qnil; if ( // we have attributes_hash !NIL_P(attributes_ctx.attributes_hash) // It's not empty && (attributes_ctx.attributes_hash_size > 0)) { volatile VALUE attribute_metadata = rb_hash_aref(attributes_ctx.attributes_hash, member); if (attribute_metadata != Qnil) { value = rb_ivar_get(attribute_metadata, value_before_type_cast_id); if (NIL_P(attribute->type)) { attribute->type = rb_ivar_get(attribute_metadata, type_id); } } } if (NIL_P(value) && !NIL_P(attributes_ctx.values)) { if (attributes_ctx.is_indexed_row == true) { value = _read_value_from_indexed_row(attributes_ctx, member); } else { value = rb_hash_aref(attributes_ctx.values, member); } } if (NIL_P(attribute->type) && !NIL_P(value)) { if (attributes_ctx.tryToReadFromAdditionalTypes == true) { attribute->type = rb_hash_aref(attributes_ctx.additional_types, member); } if (!NIL_P(attributes_ctx.types) && NIL_P(attribute->type)) { attribute->type = rb_hash_aref(attributes_ctx.types, member); } } if (!NIL_P(attribute->type) && !NIL_P(value)) { return type_cast(attribute->type, value, isJson); } return value; } void active_record_attributes_writer(VALUE obj, VALUE attributes, EachAttributeFunc write_value, VALUE writer) { long i; struct attributes attributes_ctx = init_context(obj); volatile VALUE record_class = CLASS_OF(obj); for (i = 0; i < RARRAY_LEN(attributes); i++) { volatile VALUE raw_attribute = RARRAY_AREF(attributes, i); Attribute attribute = PANKO_ATTRIBUTE_READ(raw_attribute); attribute_try_invalidate(attribute, record_class); volatile VALUE isJson = Qfalse; volatile VALUE value = read_attribute(attributes_ctx, attribute, &isJson); write_value(writer, attr_name_for_serialization(attribute), value, isJson); } } void init_active_record_attributes_writer(VALUE mPanko) { attributes_id = rb_intern("@attributes"); delegate_hash_id = rb_intern("@delegate_hash"); values_id = rb_intern("@values"); types_id = rb_intern("@types"); additional_types_id = rb_intern("@additional_types"); type_id = rb_intern("@type"); value_before_type_cast_id = rb_intern("@value_before_type_cast"); fetch_id = rb_intern("fetch"); } void panko_init_active_record(VALUE mPanko) { init_active_record_attributes_writer(mPanko); panko_init_type_cast(mPanko); } ================================================ FILE: ext/panko_serializer/attributes_writer/active_record.h ================================================ #pragma once #include #include #include "../common.h" #include "common.h" #include "serialization_descriptor/attribute.h" #include "type_cast/type_cast.h" extern void active_record_attributes_writer(VALUE object, VALUE attributes, EachAttributeFunc func, VALUE writer); void init_active_record_attributes_writer(VALUE mPanko); void panko_init_active_record(VALUE mPanko); ================================================ FILE: ext/panko_serializer/attributes_writer/attributes_writer.c ================================================ #include "attributes_writer.h" static bool types_initialized = false; static VALUE ar_base_type = Qundef; VALUE init_types(VALUE v) { if (types_initialized == true) { return Qundef; } types_initialized = true; volatile VALUE ar_type = rb_const_get_at(rb_cObject, rb_intern("ActiveRecord")); ar_base_type = rb_const_get_at(ar_type, rb_intern("Base")); rb_global_variable(&ar_base_type); return Qundef; } AttributesWriter create_attributes_writer(VALUE object) { // If ActiveRecord::Base can't be found it will throw error int isErrored; rb_protect(init_types, Qnil, &isErrored); if (ar_base_type != Qundef && rb_obj_is_kind_of(object, ar_base_type) == Qtrue) { return (AttributesWriter){ .object_type = ActiveRecord, .write_attributes = active_record_attributes_writer}; } if (!RB_SPECIAL_CONST_P(object) && BUILTIN_TYPE(object) == T_HASH) { return (AttributesWriter){.object_type = Hash, .write_attributes = hash_attributes_writer}; } return (AttributesWriter){.object_type = Plain, .write_attributes = plain_attributes_writer}; return create_empty_attributes_writer(); } void empty_write_attributes(VALUE obj, VALUE attributes, EachAttributeFunc func, VALUE writer) {} AttributesWriter create_empty_attributes_writer() { return (AttributesWriter){.object_type = UnknownObjectType, .write_attributes = empty_write_attributes}; } void init_attributes_writer(VALUE mPanko) { init_active_record_attributes_writer(mPanko); } ================================================ FILE: ext/panko_serializer/attributes_writer/attributes_writer.h ================================================ #pragma once #include #include "active_record.h" #include "common.h" #include "hash.h" #include "plain.h" enum ObjectType { UnknownObjectType = 0, ActiveRecord = 1, Plain = 2, Hash = 3 }; typedef struct _AttributesWriter { enum ObjectType object_type; void (*write_attributes)(VALUE object, VALUE attributes, EachAttributeFunc func, VALUE context); } AttributesWriter; /** * Infers the attributes writer from the object type */ AttributesWriter create_attributes_writer(VALUE object); /** * Creates empty writer * Useful when the writer is not known, and you need init something */ AttributesWriter create_empty_attributes_writer(); void init_attributes_writer(VALUE mPanko); ================================================ FILE: ext/panko_serializer/attributes_writer/common.c ================================================ #include "common.h" VALUE attr_name_for_serialization(Attribute attribute) { volatile VALUE name_str = attribute->name_str; if (attribute->alias_name != Qnil) { name_str = attribute->alias_name; } return name_str; } ================================================ FILE: ext/panko_serializer/attributes_writer/common.h ================================================ #pragma once #include "../serialization_descriptor/attribute.h" #include "ruby.h" typedef void (*EachAttributeFunc)(VALUE writer, VALUE name, VALUE value, VALUE isJson); VALUE attr_name_for_serialization(Attribute attribute); ================================================ FILE: ext/panko_serializer/attributes_writer/hash.c ================================================ #include "hash.h" void hash_attributes_writer(VALUE obj, VALUE attributes, EachAttributeFunc write_value, VALUE writer) { long i; for (i = 0; i < RARRAY_LEN(attributes); i++) { volatile VALUE raw_attribute = RARRAY_AREF(attributes, i); Attribute attribute = attribute_read(raw_attribute); write_value(writer, attr_name_for_serialization(attribute), rb_hash_aref(obj, attribute->name_str), Qfalse); } } ================================================ FILE: ext/panko_serializer/attributes_writer/hash.h ================================================ #pragma once #include "common.h" #include "ruby.h" void hash_attributes_writer(VALUE obj, VALUE attributes, EachAttributeFunc func, VALUE writer); ================================================ FILE: ext/panko_serializer/attributes_writer/plain.c ================================================ #include "plain.h" void plain_attributes_writer(VALUE obj, VALUE attributes, EachAttributeFunc write_value, VALUE writer) { long i; for (i = 0; i < RARRAY_LEN(attributes); i++) { volatile VALUE raw_attribute = RARRAY_AREF(attributes, i); Attribute attribute = attribute_read(raw_attribute); write_value(writer, attr_name_for_serialization(attribute), rb_funcall(obj, attribute->name_id, 0), Qfalse); } } ================================================ FILE: ext/panko_serializer/attributes_writer/plain.h ================================================ #pragma once #include "common.h" #include "ruby.h" void plain_attributes_writer(VALUE obj, VALUE attributes, EachAttributeFunc func, VALUE writer); ================================================ FILE: ext/panko_serializer/attributes_writer/type_cast/time_conversion.c ================================================ #include "time_conversion.h" const int YEAR_REGION = 1; const int MONTH_REGION = 2; const int DAY_REGION = 3; const int HOUR_REGION = 4; const int MINUTE_REGION = 5; const int SECOND_REGION = 6; static regex_t* iso8601_time_regex; static regex_t* ar_iso_datetime_regex; VALUE is_iso8601_time_string(const char* value) { const UChar *start, *range, *end; OnigPosition r; const UChar* str = (const UChar*)(value); end = str + strlen(value); start = str; range = end; r = onig_search(iso8601_time_regex, str, end, start, range, NULL, ONIG_OPTION_NONE); return r >= 0 ? Qtrue : Qfalse; } void append_region_str(const char* source, char** to, int regionBegin, int regionEnd) { long iter = 0; for (iter = regionBegin; iter < regionEnd; iter++) { *(*to)++ = source[iter]; } } bool is_iso_ar_iso_datetime_string_fast_case(const char* value) { return ( // year isdigit(value[0]) && isdigit(value[1]) && isdigit(value[2]) && isdigit(value[3]) && value[4] == '-' && // month isdigit(value[5]) && isdigit(value[6]) && value[7] == '-' && // mday isdigit(value[8]) && isdigit(value[9]) && value[10] == ' ' && // hour isdigit(value[11]) && isdigit(value[12]) && value[13] == ':' && // minute isdigit(value[14]) && isdigit(value[15]) && value[16] == ':' && // seconds isdigit(value[17]) && isdigit(value[18])); } bool is_iso_ar_iso_datetime_string_slow_case(const char* value) { const UChar *start, *range, *end; OnigPosition r; OnigRegion* region = onig_region_new(); const UChar* str = (const UChar*)(value); end = str + strlen(value); start = str; range = end; r = onig_search(ar_iso_datetime_regex, str, end, start, range, region, ONIG_OPTION_NONE); onig_region_free(region, 1); return (r >= 0); } VALUE iso_ar_iso_datetime_string(const char* value) { if (is_iso_ar_iso_datetime_string_fast_case(value) == true || is_iso_ar_iso_datetime_string_slow_case(value) == true) { volatile VALUE output; char buf[24] = ""; char* cur = buf; append_region_str(value, &cur, 0, 4); *cur++ = '-'; append_region_str(value, &cur, 5, 7); *cur++ = '-'; append_region_str(value, &cur, 8, 10); *cur++ = 'T'; append_region_str(value, &cur, 11, 13); *cur++ = ':'; append_region_str(value, &cur, 14, 16); *cur++ = ':'; append_region_str(value, &cur, 17, 19); *cur++ = '.'; if (value[19] == '.' && isdigit(value[20])) { if (isdigit(value[20])) { *cur++ = value[20]; } else { *cur++ = '0'; } if (isdigit(value[21])) { *cur++ = value[21]; } else { *cur++ = '0'; } if (isdigit(value[22])) { *cur++ = value[22]; } else { *cur++ = '0'; } } else { *cur++ = '0'; *cur++ = '0'; *cur++ = '0'; } *cur++ = 'Z'; output = rb_str_new(buf, cur - buf); return output; } return Qnil; } void build_regex(OnigRegex* reg, const UChar* pattern) { OnigErrorInfo einfo; int r = onig_new(reg, pattern, pattern + strlen((char*)pattern), ONIG_OPTION_DEFAULT, ONIG_ENCODING_ASCII, ONIG_SYNTAX_DEFAULT, &einfo); if (r != ONIG_NORMAL) { char s[ONIG_MAX_ERROR_MESSAGE_LEN]; onig_error_code_to_str((UChar*)s, r, &einfo); printf("ERROR: %s\n", s); } } void panko_init_time(VALUE mPanko) { const UChar *ISO8601_PATTERN, *AR_ISO_DATETIME_PATTERN; ISO8601_PATTERN = (UChar*)"^([\\+-]?\\d{4}(?!\\d{2}\\b))((-?)((0[1-9]|1[0-2])(\\3([12]\\d|0[1-9]|3[01]))?|W([0-4]\\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\\d|[12]\\d{2}|3([0-5]\\d|6[1-6])))([T\\s]((([01]\\d|2[0-3])((:?)[0-5]\\d)?|24\\:?00)([\\.,]\\d+(?!:))?)?(\\17[0-5]\\d([\\.,]\\d+)?)?([zZ]|([\\+-])([01]\\d|2[0-3]):?([0-5]\\d)?)?)?)?$"; build_regex(&iso8601_time_regex, ISO8601_PATTERN); AR_ISO_DATETIME_PATTERN = (UChar*)"\\A(?\\d{4})-(?\\d\\d)-(?\\d\\d) (?\\d\\d):(?\\d\\d):(?\\d\\d)(\\.(?\\d+))?\\z"; build_regex(&ar_iso_datetime_regex, AR_ISO_DATETIME_PATTERN); } ================================================ FILE: ext/panko_serializer/attributes_writer/type_cast/time_conversion.h ================================================ #pragma once #include #include #include #include VALUE is_iso8601_time_string(const char* value); VALUE iso_ar_iso_datetime_string(const char* value); void panko_init_time(VALUE mPanko); ================================================ FILE: ext/panko_serializer/attributes_writer/type_cast/type_cast.c ================================================ #include "type_cast.h" #include "time_conversion.h" ID deserialize_from_db_id = 0; ID to_s_id = 0; ID to_i_id = 0; static VALUE oj_type = Qundef; static VALUE oj_parseerror_type = Qundef; ID oj_sc_parse_id = 0; // Caching ActiveRecord Types static VALUE ar_string_type = Qundef; static VALUE ar_text_type = Qundef; static VALUE ar_float_type = Qundef; static VALUE ar_integer_type = Qundef; static VALUE ar_boolean_type = Qundef; static VALUE ar_date_time_type = Qundef; static VALUE ar_time_zone_converter = Qundef; static VALUE ar_json_type = Qundef; static VALUE ar_pg_integer_type = Qundef; static VALUE ar_pg_float_type = Qundef; static VALUE ar_pg_uuid_type = Qundef; static VALUE ar_pg_json_type = Qundef; static VALUE ar_pg_jsonb_type = Qundef; static VALUE ar_pg_array_type = Qundef; static VALUE ar_pg_date_time_type = Qundef; static VALUE ar_pg_timestamp_type = Qundef; static int initiailized = 0; VALUE cache_postgres_type_lookup(VALUE ar) { VALUE ar_connection_adapters, ar_postgresql, ar_oid; if (rb_const_defined_at(ar, rb_intern("ConnectionAdapters")) != (int)Qtrue) { return Qfalse; } ar_connection_adapters = rb_const_get_at(ar, rb_intern("ConnectionAdapters")); if (rb_const_defined_at(ar_connection_adapters, rb_intern("PostgreSQL")) != (int)Qtrue) { return Qfalse; } ar_postgresql = rb_const_get_at(ar_connection_adapters, rb_intern("PostgreSQL")); if (rb_const_defined_at(ar_postgresql, rb_intern("OID")) != (int)Qtrue) { return Qfalse; } ar_oid = rb_const_get_at(ar_postgresql, rb_intern("OID")); if (rb_const_defined_at(ar_oid, rb_intern("Float")) == (int)Qtrue) { ar_pg_float_type = rb_const_get_at(ar_oid, rb_intern("Float")); } if (rb_const_defined_at(ar_oid, rb_intern("Integer")) == (int)Qtrue) { ar_pg_integer_type = rb_const_get_at(ar_oid, rb_intern("Integer")); } if (rb_const_defined_at(ar_oid, rb_intern("Uuid")) == (int)Qtrue) { ar_pg_uuid_type = rb_const_get_at(ar_oid, rb_intern("Uuid")); } if (rb_const_defined_at(ar_oid, rb_intern("Json")) == (int)Qtrue) { ar_pg_json_type = rb_const_get_at(ar_oid, rb_intern("Json")); } if (rb_const_defined_at(ar_oid, rb_intern("Jsonb")) == (int)Qtrue) { ar_pg_jsonb_type = rb_const_get_at(ar_oid, rb_intern("Jsonb")); } if (rb_const_defined_at(ar_oid, rb_intern("DateTime")) == (int)Qtrue) { ar_pg_date_time_type = rb_const_get_at(ar_oid, rb_intern("DateTime")); } if (rb_const_defined_at(ar_oid, rb_intern("Timestamp")) == (int)Qtrue) { ar_pg_timestamp_type = rb_const_get_at(ar_oid, rb_intern("Timestamp")); } return Qtrue; } VALUE cache_time_zone_type_lookup(VALUE ar) { VALUE ar_attr_methods, ar_time_zone_conversion; // ActiveRecord::AttributeMethods if (rb_const_defined_at(ar, rb_intern("AttributeMethods")) != (int)Qtrue) { return Qfalse; } ar_attr_methods = rb_const_get_at(ar, rb_intern("AttributeMethods")); // ActiveRecord::AttributeMethods::TimeZoneConversion if (rb_const_defined_at(ar_attr_methods, rb_intern("TimeZoneConversion")) != (int)Qtrue) { return Qfalse; } ar_time_zone_conversion = rb_const_get_at(ar_attr_methods, rb_intern("TimeZoneConversion")); if (rb_const_defined_at(ar_time_zone_conversion, rb_intern("TimeZoneConverter")) != (int)Qtrue) { return Qfalse; } ar_time_zone_converter = rb_const_get_at(ar_time_zone_conversion, rb_intern("TimeZoneConverter")); return Qtrue; } void cache_type_lookup() { if (initiailized == 1) { return; } initiailized = 1; VALUE ar, ar_type, ar_type_methods; ar = rb_const_get_at(rb_cObject, rb_intern("ActiveRecord")); // ActiveRecord::Type ar_type = rb_const_get_at(ar, rb_intern("Type")); ar_string_type = rb_const_get_at(ar_type, rb_intern("String")); ar_text_type = rb_const_get_at(ar_type, rb_intern("Text")); ar_float_type = rb_const_get_at(ar_type, rb_intern("Float")); ar_integer_type = rb_const_get_at(ar_type, rb_intern("Integer")); ar_boolean_type = rb_const_get_at(ar_type, rb_intern("Boolean")); ar_date_time_type = rb_const_get_at(ar_type, rb_intern("DateTime")); ar_type_methods = rb_class_instance_methods(0, NULL, ar_string_type); if (rb_ary_includes(ar_type_methods, rb_to_symbol(rb_str_new_cstr("deserialize")))) { deserialize_from_db_id = rb_intern("deserialize"); } else { deserialize_from_db_id = rb_intern("type_cast_from_database"); } if (rb_const_defined_at(ar_type, rb_intern("Json")) == (int)Qtrue) { ar_json_type = rb_const_get_at(ar_type, rb_intern("Json")); } int isErrored; rb_protect(cache_postgres_type_lookup, ar, &isErrored); if (isErrored) { rb_set_errinfo(Qnil); } rb_protect(cache_time_zone_type_lookup, ar, &isErrored); if (isErrored) { rb_set_errinfo(Qnil); } } bool is_string_or_text_type(VALUE type_klass) { return type_klass == ar_string_type || type_klass == ar_text_type || (ar_pg_uuid_type != Qundef && type_klass == ar_pg_uuid_type); } VALUE cast_string_or_text_type(VALUE value) { if (RB_TYPE_P(value, T_STRING)) { return value; } if (value == Qtrue) { return rb_str_new_cstr("t"); } if (value == Qfalse) { return rb_str_new_cstr("f"); } return rb_funcall(value, to_s_id, 0); } bool is_float_type(VALUE type_klass) { return type_klass == ar_float_type || (ar_pg_float_type != Qundef && type_klass == ar_pg_float_type); } VALUE cast_float_type(VALUE value) { if (RB_TYPE_P(value, T_FLOAT)) { return value; } if (RB_TYPE_P(value, T_STRING)) { const char* val = StringValuePtr(value); return rb_float_new(strtod(val, NULL)); } return Qundef; } bool is_integer_type(VALUE type_klass) { return type_klass == ar_integer_type || (ar_pg_integer_type != Qundef && type_klass == ar_pg_integer_type); } VALUE cast_integer_type(VALUE value) { if (RB_INTEGER_TYPE_P(value)) { return value; } if (RB_TYPE_P(value, T_STRING)) { const char* val = StringValuePtr(value); if (strlen(val) == 0) { return Qnil; } return rb_cstr2inum(val, 10); } if (RB_FLOAT_TYPE_P(value)) { // We are calling the `to_i` here, because ruby internal // `flo_to_i` is not accessible return rb_funcall(value, to_i_id, 0); } if (value == Qtrue) { return INT2NUM(1); } if (value == Qfalse) { return INT2NUM(0); } // At this point, we handled integer, float, string and booleans // any thing other than this (array, hashes, etc) should result in nil return Qnil; } bool is_json_type(VALUE type_klass) { return ((ar_pg_json_type != Qundef && type_klass == ar_pg_json_type) || (ar_pg_jsonb_type != Qundef && type_klass == ar_pg_jsonb_type) || (ar_json_type != Qundef && type_klass == ar_json_type)); } bool is_boolean_type(VALUE type_klass) { return type_klass == ar_boolean_type; } VALUE cast_boolean_type(VALUE value) { if (value == Qtrue || value == Qfalse) { return value; } if (value == Qnil) { return Qnil; } if (RB_TYPE_P(value, T_STRING)) { if (RSTRING_LEN(value) == 0) { return Qnil; } const char* val = StringValuePtr(value); bool isFalseValue = (*val == '0' || (*val == 'f' || *val == 'F') || (strcmp(val, "false") == 0 || strcmp(val, "FALSE") == 0) || (strcmp(val, "off") == 0 || strcmp(val, "OFF") == 0)); return isFalseValue ? Qfalse : Qtrue; } if (RB_INTEGER_TYPE_P(value)) { return value == INT2NUM(1) ? Qtrue : Qfalse; } return Qundef; } bool is_date_time_type(VALUE type_klass) { return (type_klass == ar_date_time_type) || (ar_pg_date_time_type != Qundef && type_klass == ar_pg_date_time_type) || (ar_pg_timestamp_type != Qundef && type_klass == ar_pg_timestamp_type) || (ar_time_zone_converter != Qundef && type_klass == ar_time_zone_converter); } VALUE cast_date_time_type(VALUE value) { // Instead of take strings to comparing them to time zones // and then comparing them back to string // We will just make sure we have string on ISO8601 and it's utc if (RB_TYPE_P(value, T_STRING)) { const char* val = StringValuePtr(value); // 'Z' in ISO8601 says it's UTC if (val[strlen(val) - 1] == 'Z' && is_iso8601_time_string(val) == Qtrue) { return value; } volatile VALUE iso8601_string = iso_ar_iso_datetime_string(val); if (iso8601_string != Qnil) { return iso8601_string; } } return Qundef; } VALUE rescue_func(VALUE _arg, VALUE _data) { return Qfalse; } VALUE parse_json(VALUE value) { return rb_funcall(oj_type, oj_sc_parse_id, 2, rb_cObject, value); } VALUE is_json_value(VALUE value) { if (!RB_TYPE_P(value, T_STRING)) { return value; } if (RSTRING_LEN(value) == 0) { return Qfalse; } volatile VALUE result = rb_rescue2(parse_json, value, rescue_func, Qundef, oj_parseerror_type, 0); if (NIL_P(result)) { return Qtrue; } if (result == Qfalse) { return Qfalse; } // TODO: fix me! return Qfalse; } VALUE type_cast(VALUE type_metadata, VALUE value, volatile VALUE* isJson) { if (value == Qnil || value == Qundef) { return value; } cache_type_lookup(); VALUE type_klass, typeCastedValue; type_klass = CLASS_OF(type_metadata); typeCastedValue = Qundef; TypeCast typeCast; for (typeCast = type_casts; typeCast->canCast != NULL; typeCast++) { if (typeCast->canCast(type_klass)) { typeCastedValue = typeCast->typeCast(value); break; } } if (is_json_type(type_klass)) { if (is_json_value(value) == Qfalse) { return Qnil; } *isJson = Qtrue; return value; } if (typeCastedValue == Qundef) { return rb_funcall(type_metadata, deserialize_from_db_id, 1, value); } return typeCastedValue; } VALUE public_type_cast(int argc, VALUE* argv, VALUE self) { VALUE type_metadata, value, isJson; rb_scan_args(argc, argv, "21", &type_metadata, &value, &isJson); if (isJson == Qnil || isJson == Qundef) { isJson = Qfalse; } return type_cast(type_metadata, value, &isJson); } void panko_init_type_cast(VALUE mPanko) { to_s_id = rb_intern("to_s"); to_i_id = rb_intern("to_i"); oj_type = rb_const_get_at(rb_cObject, rb_intern("Oj")); oj_parseerror_type = rb_const_get_at(oj_type, rb_intern("ParseError")); oj_sc_parse_id = rb_intern("sc_parse"); // TODO: pass 3 arguments here rb_define_singleton_method(mPanko, "_type_cast", public_type_cast, -1); panko_init_time(mPanko); rb_global_variable(&oj_type); rb_global_variable(&oj_parseerror_type); rb_global_variable(&ar_string_type); rb_global_variable(&ar_text_type); rb_global_variable(&ar_float_type); rb_global_variable(&ar_integer_type); rb_global_variable(&ar_boolean_type); rb_global_variable(&ar_date_time_type); rb_global_variable(&ar_time_zone_converter); rb_global_variable(&ar_json_type); rb_global_variable(&ar_pg_integer_type); rb_global_variable(&ar_pg_float_type); rb_global_variable(&ar_pg_uuid_type); rb_global_variable(&ar_pg_json_type); rb_global_variable(&ar_pg_jsonb_type); rb_global_variable(&ar_pg_array_type); rb_global_variable(&ar_pg_date_time_type); rb_global_variable(&ar_pg_timestamp_type); } ================================================ FILE: ext/panko_serializer/attributes_writer/type_cast/type_cast.h ================================================ #pragma once #include #include /* * Type Casting * * We do "special" type casting which is mix of two inspirations: * *) light records gem * *) pg TextDecoders * * The whole idea behind those type casts, are to do the minimum required * type casting in the most performant manner and *allocation free*. * * For example, in `ActiveRecord::Type::String` the type_cast_from_database * creates new string, for known reasons, but, in serialization flow we don't * need to create new string becuase we afraid of mutations. * * Since we know before hand, that we are only reading from the database, and * *not* writing and the end result if for JSON we can skip some "defenses". */ typedef bool (*TypeMatchFunc)(VALUE type_klass); /* * TypeCastFunc * * @return VALUE casted value or Qundef if not casted */ typedef VALUE (*TypeCastFunc)(VALUE value); typedef struct _TypeCast { TypeMatchFunc canCast; TypeCastFunc typeCast; }* TypeCast; // ActiveRecord::Type::String // ActiveRecord::Type::Text bool is_string_or_text_type(VALUE type_klass); VALUE cast_string_or_text_type(VALUE value); // ActiveRecord::Type::Float bool is_float_type(VALUE type_klass); VALUE cast_float_type(VALUE value); // ActiveRecord::Type::Integer bool is_integer_type(VALUE type_klass); VALUE cast_integer_type(VALUE value); // ActiveRecord::ConnectoinAdapters::PostgreSQL::Json bool is_json_type(VALUE type_klass); VALUE cast_json_type(VALUE value); // ActiveRecord::Type::Boolean bool is_boolean_type(VALUE type_klass); VALUE cast_boolean_type(VALUE value); // ActiveRecord::Type::DateTime // ActiveRecord::ConnectionAdapters::PostgreSQL::OID::DateTime // ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter bool is_date_time_type(VALUE type_klass); VALUE cast_date_time_type(VALUE value); static struct _TypeCast type_casts[] = { {is_string_or_text_type, cast_string_or_text_type}, {is_integer_type, cast_integer_type}, {is_boolean_type, cast_boolean_type}, {is_date_time_type, cast_date_time_type}, {is_float_type, cast_float_type}, {NULL, NULL}}; extern VALUE type_cast(VALUE type_metadata, VALUE value, volatile VALUE* isJson); void panko_init_type_cast(VALUE mPanko); // Introduced in ruby 2.4 #ifndef RB_INTEGER_TYPE_P #define RB_INTEGER_TYPE_P(obj) (RB_FIXNUM_P(obj) || RB_TYPE_P(obj, T_BIGNUM)) #endif ================================================ FILE: ext/panko_serializer/common.h ================================================ #pragma once #include #define PANKO_SAFE_HASH_SIZE(hash) \ (hash == Qnil || hash == Qundef) ? 0 : RHASH_SIZE(hash) #define PANKO_EMPTY_HASH(hash) \ (hash == Qnil || hash == Qundef) ? 1 : (RHASH_SIZE(hash) == 0) ================================================ FILE: ext/panko_serializer/extconf.rb ================================================ # frozen_string_literal: true require "mkmf" require "pathname" $CPPFLAGS += " -Wall" extension_name = "panko_serializer" dir_config(extension_name) RbConfig.expand(srcdir = "$(srcdir)".dup) # enum all source files $srcs = Dir[File.join(srcdir, "**/*.c")] # Get all source directories recursivley directories = Dir[File.join(srcdir, "**/*")].select { |f| File.directory?(f) } directories = directories.map { |d| Pathname.new(d).relative_path_from(Pathname.new(srcdir)) } directories.each do |dir| # add include path to the internal folder # $(srcdir) is a root folder, where "extconf.rb" is stored $INCFLAGS << " -I$(srcdir)/#{dir}" # add folder, where compiler can search source files $VPATH << "$(srcdir)/#{dir}" end create_makefile("panko/panko_serializer") ================================================ FILE: ext/panko_serializer/panko_serializer.c ================================================ #include "panko_serializer.h" #include static ID push_value_id; static ID push_array_id; static ID push_object_id; static ID push_json_id; static ID pop_id; static ID to_a_id; static ID object_id; static ID serialization_context_id; static VALUE SKIP = Qundef; void write_value(VALUE str_writer, VALUE key, VALUE value, VALUE isJson) { if (isJson == Qtrue) { rb_funcall(str_writer, push_json_id, 2, value, key); } else { rb_funcall(str_writer, push_value_id, 2, value, key); } } void serialize_method_fields(VALUE object, VALUE str_writer, SerializationDescriptor descriptor) { if (RARRAY_LEN(descriptor->method_fields) == 0) { return; } volatile VALUE method_fields, serializer, key; long i; method_fields = descriptor->method_fields; serializer = descriptor->serializer; rb_ivar_set(serializer, object_id, object); for (i = 0; i < RARRAY_LEN(method_fields); i++) { volatile VALUE raw_attribute = RARRAY_AREF(method_fields, i); Attribute attribute = PANKO_ATTRIBUTE_READ(raw_attribute); volatile VALUE result = rb_funcall(serializer, attribute->name_id, 0); if (result != SKIP) { key = attr_name_for_serialization(attribute); write_value(str_writer, key, result, Qfalse); } } rb_ivar_set(serializer, object_id, Qnil); } void serialize_fields(VALUE object, VALUE str_writer, SerializationDescriptor descriptor) { descriptor->attributes_writer.write_attributes(object, descriptor->attributes, write_value, str_writer); serialize_method_fields(object, str_writer, descriptor); } void serialize_has_one_associations(VALUE object, VALUE str_writer, VALUE associations) { long i; for (i = 0; i < RARRAY_LEN(associations); i++) { volatile VALUE association_el = RARRAY_AREF(associations, i); Association association = association_read(association_el); volatile VALUE value = rb_funcall(object, association->name_id, 0); if (NIL_P(value)) { write_value(str_writer, association->name_str, value, Qfalse); } else { serialize_object(association->name_str, value, str_writer, association->descriptor); } } } void serialize_has_many_associations(VALUE object, VALUE str_writer, VALUE associations) { long i; for (i = 0; i < RARRAY_LEN(associations); i++) { volatile VALUE association_el = RARRAY_AREF(associations, i); Association association = association_read(association_el); volatile VALUE value = rb_funcall(object, association->name_id, 0); if (NIL_P(value)) { write_value(str_writer, association->name_str, value, Qfalse); } else { serialize_objects(association->name_str, value, str_writer, association->descriptor); } } } VALUE serialize_object(VALUE key, VALUE object, VALUE str_writer, SerializationDescriptor descriptor) { sd_set_writer(descriptor, object); rb_funcall(str_writer, push_object_id, 1, key); serialize_fields(object, str_writer, descriptor); if (RARRAY_LEN(descriptor->has_one_associations) > 0) { serialize_has_one_associations(object, str_writer, descriptor->has_one_associations); } if (RARRAY_LEN(descriptor->has_many_associations) > 0) { serialize_has_many_associations(object, str_writer, descriptor->has_many_associations); } rb_funcall(str_writer, pop_id, 0); return Qnil; } VALUE serialize_objects(VALUE key, VALUE objects, VALUE str_writer, SerializationDescriptor descriptor) { long i; rb_funcall(str_writer, push_array_id, 1, key); if (!RB_TYPE_P(objects, T_ARRAY)) { objects = rb_funcall(objects, to_a_id, 0); } for (i = 0; i < RARRAY_LEN(objects); i++) { volatile VALUE object = RARRAY_AREF(objects, i); serialize_object(Qnil, object, str_writer, descriptor); } rb_funcall(str_writer, pop_id, 0); return Qnil; } VALUE serialize_object_api(VALUE klass, VALUE object, VALUE str_writer, VALUE descriptor) { SerializationDescriptor sd = sd_read(descriptor); return serialize_object(Qnil, object, str_writer, sd); } VALUE serialize_objects_api(VALUE klass, VALUE objects, VALUE str_writer, VALUE descriptor) { serialize_objects(Qnil, objects, str_writer, sd_read(descriptor)); return Qnil; } void Init_panko_serializer() { push_value_id = rb_intern("push_value"); push_array_id = rb_intern("push_array"); push_object_id = rb_intern("push_object"); push_json_id = rb_intern("push_json"); pop_id = rb_intern("pop"); to_a_id = rb_intern("to_a"); object_id = rb_intern("@object"); serialization_context_id = rb_intern("@serialization_context"); VALUE mPanko = rb_define_module("Panko"); rb_define_singleton_method(mPanko, "serialize_object", serialize_object_api, 3); rb_define_singleton_method(mPanko, "serialize_objects", serialize_objects_api, 3); VALUE mPankoSerializer = rb_const_get(mPanko, rb_intern("Serializer")); SKIP = rb_const_get(mPankoSerializer, rb_intern("SKIP")); rb_global_variable(&SKIP); panko_init_serialization_descriptor(mPanko); init_attributes_writer(mPanko); panko_init_type_cast(mPanko); panko_init_attribute(mPanko); panko_init_association(mPanko); } ================================================ FILE: ext/panko_serializer/panko_serializer.h ================================================ #include #include "attributes_writer/attributes_writer.h" #include "serialization_descriptor/association.h" #include "serialization_descriptor/attribute.h" #include "serialization_descriptor/serialization_descriptor.h" VALUE serialize_object(VALUE key, VALUE object, VALUE str_writer, SerializationDescriptor descriptor); VALUE serialize_objects(VALUE key, VALUE objects, VALUE str_writer, SerializationDescriptor descriptor); ================================================ FILE: ext/panko_serializer/serialization_descriptor/association.c ================================================ #include "association.h" VALUE cAssociation; static void association_free(void* ptr) { if (!ptr) { return; } Association association = (Association)ptr; association->name_str = Qnil; association->name_id = 0; association->name_sym = Qnil; association->rb_descriptor = Qnil; if (!association->descriptor || association->descriptor != NULL) { association->descriptor = NULL; } xfree(association); } void association_mark(Association data) { rb_gc_mark(data->name_str); rb_gc_mark(data->name_sym); rb_gc_mark(data->rb_descriptor); if (data->descriptor != NULL) { sd_mark(data->descriptor); } } static VALUE association_new(int argc, VALUE* argv, VALUE self) { Association association; Check_Type(argv[0], T_SYMBOL); Check_Type(argv[1], T_STRING); association = ALLOC(struct _Association); association->name_sym = argv[0]; association->name_str = argv[1]; association->rb_descriptor = argv[2]; association->name_id = rb_intern_str(rb_sym2str(association->name_sym)); association->descriptor = sd_read(association->rb_descriptor); return Data_Wrap_Struct(cAssociation, association_mark, association_free, association); } Association association_read(VALUE association) { return (Association)DATA_PTR(association); } VALUE association_name_sym_ref(VALUE self) { Association association = (Association)DATA_PTR(self); return association->name_sym; } VALUE association_name_str_ref(VALUE self) { Association association = (Association)DATA_PTR(self); return association->name_str; } VALUE association_descriptor_ref(VALUE self) { Association association = (Association)DATA_PTR(self); return association->rb_descriptor; } VALUE association_decriptor_aset(VALUE self, VALUE descriptor) { Association association = (Association)DATA_PTR(self); association->rb_descriptor = descriptor; association->descriptor = sd_read(descriptor); return association->rb_descriptor; } void panko_init_association(VALUE mPanko) { cAssociation = rb_define_class_under(mPanko, "Association", rb_cObject); rb_undef_alloc_func(cAssociation); rb_global_variable(&cAssociation); rb_define_module_function(cAssociation, "new", association_new, -1); rb_define_method(cAssociation, "name_sym", association_name_sym_ref, 0); rb_define_method(cAssociation, "name_str", association_name_str_ref, 0); rb_define_method(cAssociation, "descriptor", association_descriptor_ref, 0); rb_define_method(cAssociation, "descriptor=", association_decriptor_aset, 1); } ================================================ FILE: ext/panko_serializer/serialization_descriptor/association.h ================================================ #include #ifndef __ASSOCIATION_H__ #define __ASSOCIATION_H__ #include "serialization_descriptor.h" typedef struct _Association { ID name_id; VALUE name_sym; VALUE name_str; VALUE rb_descriptor; SerializationDescriptor descriptor; }* Association; Association association_read(VALUE association); void panko_init_association(VALUE mPanko); #endif ================================================ FILE: ext/panko_serializer/serialization_descriptor/attribute.c ================================================ #include "attribute.h" ID attribute_aliases_id = 0; VALUE cAttribute; static void attribute_free(void* ptr) { if (!ptr) { return; } Attribute attribute = (Attribute)ptr; attribute->name_str = Qnil; attribute->name_id = 0; attribute->alias_name = Qnil; attribute->type = Qnil; attribute->record_class = Qnil; xfree(attribute); } void attribute_mark(Attribute data) { rb_gc_mark(data->name_str); rb_gc_mark(data->alias_name); rb_gc_mark(data->type); rb_gc_mark(data->record_class); } static VALUE attribute_new(int argc, VALUE* argv, VALUE self) { Attribute attribute; Check_Type(argv[0], T_STRING); if (argv[1] != Qnil) { Check_Type(argv[1], T_STRING); } attribute = ALLOC(struct _Attribute); attribute->name_str = argv[0]; attribute->name_id = rb_intern_str(attribute->name_str); attribute->alias_name = argv[1]; attribute->type = Qnil; attribute->record_class = Qnil; return Data_Wrap_Struct(cAttribute, attribute_mark, attribute_free, attribute); } Attribute attribute_read(VALUE attribute) { return (Attribute)DATA_PTR(attribute); } void attribute_try_invalidate(Attribute attribute, VALUE new_record_class) { if (attribute->record_class != new_record_class) { attribute->type = Qnil; attribute->record_class = new_record_class; // Once the record class is changed for this attribute, check if // we attribute_aliases (from ActivRecord), if so fill in // performance wise - this code should be called once (unless the serialzier // is polymorphic) volatile VALUE ar_aliases_hash = rb_funcall(new_record_class, attribute_aliases_id, 0); if (!PANKO_EMPTY_HASH(ar_aliases_hash)) { volatile VALUE aliasedValue = rb_hash_aref(ar_aliases_hash, attribute->name_str); if (aliasedValue != Qnil) { attribute->alias_name = attribute->name_str; attribute->name_str = aliasedValue; attribute->name_id = rb_intern_str(attribute->name_str); } } } } VALUE attribute_name_ref(VALUE self) { Attribute attribute = (Attribute)DATA_PTR(self); return attribute->name_str; } VALUE attribute_alias_name_ref(VALUE self) { Attribute attribute = (Attribute)DATA_PTR(self); return attribute->alias_name; } void panko_init_attribute(VALUE mPanko) { attribute_aliases_id = rb_intern("attribute_aliases"); cAttribute = rb_define_class_under(mPanko, "Attribute", rb_cObject); rb_undef_alloc_func(cAttribute); rb_global_variable(&cAttribute); rb_define_module_function(cAttribute, "new", attribute_new, -1); rb_define_method(cAttribute, "name", attribute_name_ref, 0); rb_define_method(cAttribute, "alias_name", attribute_alias_name_ref, 0); } ================================================ FILE: ext/panko_serializer/serialization_descriptor/attribute.h ================================================ #include #ifndef __ATTRIBUTE_H__ #define __ATTRIBUTE_H__ #include "../common.h" typedef struct _Attribute { VALUE name_str; ID name_id; VALUE alias_name; /* * We will cache the activerecord type * by the record_class */ VALUE type; VALUE record_class; }* Attribute; Attribute attribute_read(VALUE attribute); void attribute_try_invalidate(Attribute attribute, VALUE new_record_class); void panko_init_attribute(VALUE mPanko); #define PANKO_ATTRIBUTE_READ(attribute) (Attribute) DATA_PTR(attribute) #endif ================================================ FILE: ext/panko_serializer/serialization_descriptor/serialization_descriptor.c ================================================ #include "serialization_descriptor.h" static ID object_id; static ID sc_id; static void sd_free(SerializationDescriptor sd) { if (!sd) { return; } sd->serializer = Qnil; sd->serializer_type = Qnil; sd->attributes = Qnil; sd->method_fields = Qnil; sd->has_one_associations = Qnil; sd->has_many_associations = Qnil; sd->aliases = Qnil; xfree(sd); } void sd_mark(SerializationDescriptor data) { rb_gc_mark(data->serializer); rb_gc_mark(data->serializer_type); rb_gc_mark(data->attributes); rb_gc_mark(data->method_fields); rb_gc_mark(data->has_one_associations); rb_gc_mark(data->has_many_associations); rb_gc_mark(data->aliases); } static VALUE sd_alloc(VALUE klass) { SerializationDescriptor sd = ALLOC(struct _SerializationDescriptor); sd->serializer = Qnil; sd->serializer_type = Qnil; sd->attributes = Qnil; sd->method_fields = Qnil; sd->has_one_associations = Qnil; sd->has_many_associations = Qnil; sd->aliases = Qnil; sd->attributes_writer = create_empty_attributes_writer(); return Data_Wrap_Struct(klass, sd_mark, sd_free, sd); } SerializationDescriptor sd_read(VALUE descriptor) { return (SerializationDescriptor)DATA_PTR(descriptor); } void sd_set_writer(SerializationDescriptor sd, VALUE object) { if (sd->attributes_writer.object_type != UnknownObjectType) { return; } sd->attributes_writer = create_attributes_writer(object); } VALUE sd_serializer_set(VALUE self, VALUE serializer) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); sd->serializer = serializer; return Qnil; } VALUE sd_serializer_ref(VALUE self) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); return sd->serializer; } VALUE sd_attributes_set(VALUE self, VALUE attributes) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); sd->attributes = attributes; return Qnil; } VALUE sd_attributes_ref(VALUE self) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); return sd->attributes; } VALUE sd_method_fields_set(VALUE self, VALUE method_fields) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); sd->method_fields = method_fields; return Qnil; } VALUE sd_method_fields_ref(VALUE self) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); return sd->method_fields; } VALUE sd_has_one_associations_set(VALUE self, VALUE has_one_associations) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); sd->has_one_associations = has_one_associations; return Qnil; } VALUE sd_has_one_associations_ref(VALUE self) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); return sd->has_one_associations; } VALUE sd_has_many_associations_set(VALUE self, VALUE has_many_associations) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); sd->has_many_associations = has_many_associations; return Qnil; } VALUE sd_has_many_associations_ref(VALUE self) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); return sd->has_many_associations; } VALUE sd_type_set(VALUE self, VALUE type) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); sd->serializer_type = type; return Qnil; } VALUE sd_type_aref(VALUE self) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); return sd->serializer_type; } VALUE sd_aliases_set(VALUE self, VALUE aliases) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); sd->aliases = aliases; return Qnil; } VALUE sd_aliases_aref(VALUE self) { SerializationDescriptor sd = (SerializationDescriptor)DATA_PTR(self); return sd->aliases; } void panko_init_serialization_descriptor(VALUE mPanko) { object_id = rb_intern("@object"); sc_id = rb_intern("@sc"); VALUE cSerializationDescriptor = rb_define_class_under(mPanko, "SerializationDescriptor", rb_cObject); rb_define_alloc_func(cSerializationDescriptor, sd_alloc); rb_define_method(cSerializationDescriptor, "serializer=", sd_serializer_set, 1); rb_define_method(cSerializationDescriptor, "serializer", sd_serializer_ref, 0); rb_define_method(cSerializationDescriptor, "attributes=", sd_attributes_set, 1); rb_define_method(cSerializationDescriptor, "attributes", sd_attributes_ref, 0); rb_define_method(cSerializationDescriptor, "method_fields=", sd_method_fields_set, 1); rb_define_method(cSerializationDescriptor, "method_fields", sd_method_fields_ref, 0); rb_define_method(cSerializationDescriptor, "has_one_associations=", sd_has_one_associations_set, 1); rb_define_method(cSerializationDescriptor, "has_one_associations", sd_has_one_associations_ref, 0); rb_define_method(cSerializationDescriptor, "has_many_associations=", sd_has_many_associations_set, 1); rb_define_method(cSerializationDescriptor, "has_many_associations", sd_has_many_associations_ref, 0); rb_define_method(cSerializationDescriptor, "type=", sd_type_set, 1); rb_define_method(cSerializationDescriptor, "type", sd_type_aref, 0); rb_define_method(cSerializationDescriptor, "aliases=", sd_aliases_set, 1); rb_define_method(cSerializationDescriptor, "aliases", sd_aliases_aref, 0); } ================================================ FILE: ext/panko_serializer/serialization_descriptor/serialization_descriptor.h ================================================ #pragma once #include #include #include "attributes_writer/attributes_writer.h" typedef struct _SerializationDescriptor { // type of the serializer, so we can create it later VALUE serializer_type; // Cached value of the serializer VALUE serializer; // Metadata VALUE attributes; VALUE aliases; VALUE method_fields; VALUE has_one_associations; VALUE has_many_associations; AttributesWriter attributes_writer; }* SerializationDescriptor; SerializationDescriptor sd_read(VALUE descriptor); void sd_mark(SerializationDescriptor data); void sd_set_writer(SerializationDescriptor sd, VALUE object); void panko_init_serialization_descriptor(VALUE mPanko); ================================================ FILE: gemfiles/7.2.0.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "activesupport", "~> 7.2.0" gem "activemodel", "~> 7.2.0" gem "activerecord", "~> 7.2.0", group: :test gem "trilogy" gem "sqlite3", "~> 1.4" group :benchmarks do gem "vernier" gem "stackprof" gem "pg" gem "benchmark-ips" gem "memory_profiler" end group :test do gem "faker" gem "temping" end group :development do gem "byebug" gem "rake" gem "rspec", "~> 3.0" gem "rake-compiler" end group :development, :test do gem "rubocop" gem "standard" gem "standard-performance" gem "rubocop-performance" gem "rubocop-rspec" end gemspec path: "../" ================================================ FILE: gemfiles/8.0.0.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "activesupport", "~> 8.0.0" gem "activemodel", "~> 8.0.0" gem "activerecord", "~> 8.0.0", group: :test gem "trilogy" gem "sqlite3", ">= 2.1" group :benchmarks do gem "vernier" gem "stackprof" gem "pg" gem "benchmark-ips" gem "memory_profiler" end group :test do gem "faker" gem "temping" end group :development do gem "byebug" gem "rake" gem "rspec", "~> 3.0" gem "rake-compiler" end group :development, :test do gem "rubocop" gem "standard" gem "standard-performance" gem "rubocop-performance" gem "rubocop-rspec" end gemspec path: "../" ================================================ FILE: gemfiles/8.1.0.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "activesupport", "~> 8.1.0" gem "activemodel", "~> 8.1.0" gem "activerecord", "~> 8.1.0", group: :test gem "trilogy" gem "sqlite3", ">= 2.1" group :benchmarks do gem "vernier" gem "stackprof" gem "pg" gem "benchmark-ips" gem "memory_profiler" end group :test do gem "faker" gem "temping" end group :development do gem "byebug" gem "rake" gem "rspec", "~> 3.0" gem "rake-compiler" end group :development, :test do gem "rubocop" gem "standard" gem "standard-performance" gem "rubocop-performance" gem "rubocop-rspec" end gemspec path: "../" ================================================ FILE: lib/panko/array_serializer.rb ================================================ # frozen_string_literal: true module Panko class ArraySerializer attr_accessor :subjects def initialize(subjects, options = {}) @subjects = subjects @each_serializer = options[:each_serializer] if @each_serializer.nil? raise ArgumentError, %{ Please pass valid each_serializer to ArraySerializer, for example: > Panko::ArraySerializer.new(posts, each_serializer: PostSerializer) } end serializer_options = { only: options.fetch(:only, []), except: options.fetch(:except, []), context: options[:context], scope: options[:scope] } @serialization_context = SerializationContext.create(options) @descriptor = Panko::SerializationDescriptor.build(@each_serializer, serializer_options, @serialization_context) end def to_json serialize_to_json @subjects end def serialize(subjects) serialize_with_writer(subjects, Panko::ObjectWriter.new).output end def to_a serialize_with_writer(@subjects, Panko::ObjectWriter.new).output end def serialize_to_json(subjects) serialize_with_writer(subjects, Oj::StringWriter.new(mode: :rails)).to_s end private def serialize_with_writer(subjects, writer) Panko.serialize_objects(subjects.to_a, writer, @descriptor) writer end end end ================================================ FILE: lib/panko/association.rb ================================================ # frozen_string_literal: true module Panko class Association def duplicate Panko::Association.new( name_sym, name_str, Panko::SerializationDescriptor.duplicate(descriptor) ) end def inspect "" end end end ================================================ FILE: lib/panko/attribute.rb ================================================ # frozen_string_literal: true module Panko class Attribute def self.create(name, alias_name: nil) alias_name = alias_name.to_s unless alias_name.nil? Attribute.new(name.to_s, alias_name) end def ==(other) return name.to_sym == other if other.is_a? Symbol return name == other.name && alias_name == other.alias_name if other.is_a? Panko::Attribute super end def hash name.to_sym.hash end def eql?(other) self.==(other) end def inspect "" end end end ================================================ FILE: lib/panko/object_writer.rb ================================================ # frozen_string_literal: true class Panko::ObjectWriter def initialize @values = [] @keys = [] @next_key = nil @output = nil end def push_object(key = nil) @values << {} @keys << key end def push_array(key = nil) @values << [] @keys << key end def push_key(key) @next_key = key end def push_value(value, key = nil) unless @next_key.nil? raise "push_value is called with key after push_key is called" unless key.nil? key = @next_key @next_key = nil end @values.last[key] = value.as_json end def push_json(value, key = nil) if value.is_a?(String) value = begin Oj.load(value) rescue nil end end push_value(value, key) end def pop result = @values.pop if @values.empty? @output = result return end scope_key = @keys.pop if scope_key.nil? @values.last << result else @values.last[scope_key] = result end end def output raise "Output is called before poping all" unless @values.empty? @output end end ================================================ FILE: lib/panko/response.rb ================================================ # frozen_string_literal: true require "oj" module Panko JsonValue = Struct.new(:value) do def self.from(value) JsonValue.new(value) end def to_json value end end class ResponseCreator def self.value(value) Panko::Response.new(value) end def self.json(value) Panko::JsonValue.from(value) end def self.array_serializer(data, serializer, options = {}) merged_options = options.merge(each_serializer: serializer) Panko::ArraySerializer.new(data, merged_options) end def self.serializer(data, serializer, options = {}) json serializer.new(options).serialize_to_json(data) end end class Response def initialize(data) @data = data end def to_json(_options = nil) writer = Oj::StringWriter.new(mode: :rails) write(writer, @data) writer.to_s end def self.create Response.new(yield ResponseCreator) end private def write(writer, data, key = nil) return write_array(writer, data, key) if data.is_a?(Array) return write_object(writer, data, key) if data.is_a?(Hash) write_value(writer, data, key) end def write_array(writer, value, key = nil) writer.push_array key value.each { |v| write(writer, v) } writer.pop end def write_object(writer, value, key = nil) writer.push_object key value.each do |entry_key, entry_value| write(writer, entry_value, entry_key.to_s) end writer.pop end def write_value(writer, value, key = nil) if value.is_a?(Panko::ArraySerializer) || value.is_a?(Panko::Serializer) || value.is_a?(Panko::Response) || value.is_a?(Panko::JsonValue) writer.push_json(value.to_json, key) else writer.push_value(value, key) end end end end ================================================ FILE: lib/panko/serialization_descriptor.rb ================================================ # frozen_string_literal: true module Panko class SerializationDescriptor # # Creates new description and apply the options # on the new descriptor # def self.build(serializer, options = {}, serialization_context = nil) backend = Panko::SerializationDescriptor.duplicate(serializer._descriptor) options.merge! serializer.filters_for(options[:context], options[:scope]) if serializer.respond_to? :filters_for backend.apply_filters(options) backend.set_serialization_context(serialization_context) backend end # # Create new descriptor with same properties # useful when you want to apply filters # def self.duplicate(descriptor) backend = Panko::SerializationDescriptor.new backend.type = descriptor.type backend.attributes = descriptor.attributes.dup backend.method_fields = descriptor.method_fields.dup backend.serializer = descriptor.type.new(_skip_init: true) unless backend.method_fields.empty? backend.has_many_associations = descriptor.has_many_associations.map(&:duplicate) backend.has_one_associations = descriptor.has_one_associations.map(&:duplicate) backend end def set_serialization_context(context) serializer.serialization_context = context if !method_fields.empty? && !serializer.nil? has_many_associations.each do |assoc| assoc.descriptor.set_serialization_context context end has_one_associations.each do |assoc| assoc.descriptor.set_serialization_context context end end # # Applies attributes and association filters # def apply_filters(options) return unless options.key?(:only) || options.key?(:except) attributes_only_filters, associations_only_filters = resolve_filters(options, :only) attributes_except_filters, associations_except_filters = resolve_filters(options, :except) self.attributes = apply_attribute_filters( attributes, attributes_only_filters, attributes_except_filters ) self.method_fields = apply_attribute_filters( method_fields, attributes_only_filters, attributes_except_filters ) unless has_many_associations.empty? self.has_many_associations = apply_association_filters( has_many_associations, {attributes: attributes_only_filters, associations: associations_only_filters}, attributes: attributes_except_filters, associations: associations_except_filters ) end unless has_one_associations.empty? self.has_one_associations = apply_association_filters( has_one_associations, {attributes: attributes_only_filters, associations: associations_only_filters}, attributes: attributes_except_filters, associations: associations_except_filters ) end end def apply_association_filters(associations, only_filters, except_filters) attributes_only_filters = only_filters[:attributes] || [] unless attributes_only_filters.empty? associations.select! do |association| attributes_only_filters.include?(association.name_sym) end end attributes_except_filters = except_filters[:attributes] || [] unless attributes_except_filters.empty? associations.reject! do |association| attributes_except_filters.include?(association.name_sym) end end associations_only_filters = only_filters[:associations] associations_except_filters = except_filters[:associations] return associations if associations_only_filters.empty? && associations_except_filters.empty? associations.map do |association| name = association.name_sym descriptor = association.descriptor only_filter = associations_only_filters[name] except_filter = associations_except_filters[name] filters = {} filters[:only] = only_filter unless only_filter.nil? filters[:except] = except_filter unless except_filter.nil? unless filters.empty? next Panko::Association.new( name, association.name_str, Panko::SerializationDescriptor.build(descriptor.type, filters) ) end association end end def resolve_filters(options, filter) filters = options.fetch(filter, {}) return filters, {} if filters.is_a? Array # hash filters looks like this # { instance: [:a], foo: [:b] } # which mean, for the current instance use `[:a]` as filter # and for association named `foo` use `[:b]` return [], {} if filters.empty? attributes_filters = filters.fetch(:instance, []) association_filters = filters.except(:instance) [attributes_filters, association_filters] end def apply_fields_filters(fields, only, except) return fields & only unless only.empty? return fields - except unless except.empty? fields end def apply_attribute_filters(attributes, only, except) unless only.empty? attributes = attributes.select do |attribute| name_to_check = attribute.name name_to_check = attribute.alias_name unless attribute.alias_name.nil? only.include?(name_to_check.to_sym) end end unless except.empty? attributes = attributes.reject do |attribute| name_to_check = attribute.name name_to_check = attribute.alias_name unless attribute.alias_name.nil? except.include?(name_to_check.to_sym) end end attributes end end end ================================================ FILE: lib/panko/serializer.rb ================================================ # frozen_string_literal: true require_relative "serialization_descriptor" require "oj" class SerializationContext attr_accessor :context, :scope def initialize(context, scope) @context = context @scope = scope end def self.create(options) if options.key?(:context) || options.key?(:scope) SerializationContext.new(options[:context], options[:scope]) else EmptySerializerContext.new end end end class EmptySerializerContext def scope nil end def context nil end end module Panko class Serializer SKIP = Object.new.freeze class << self def inherited(base) if _descriptor.nil? base._descriptor = Panko::SerializationDescriptor.new base._descriptor.attributes = [] base._descriptor.aliases = {} base._descriptor.method_fields = [] base._descriptor.has_many_associations = [] base._descriptor.has_one_associations = [] else base._descriptor = Panko::SerializationDescriptor.duplicate(_descriptor) end base._descriptor.type = base end attr_accessor :_descriptor def attributes(*attrs) @_descriptor.attributes.push(*attrs.map { |attr| Attribute.create(attr) }).uniq! end def aliases(aliases = {}) aliases.each do |attr, alias_name| @_descriptor.attributes << Attribute.create(attr, alias_name: alias_name) end end def method_added(method) super return if @_descriptor.nil? deleted_attr = @_descriptor.attributes.delete(method) @_descriptor.method_fields << Attribute.create(deleted_attr.name, alias_name: deleted_attr.alias_name) unless deleted_attr.nil? end def has_one(name, options = {}) serializer_const = options[:serializer] if serializer_const.is_a?(String) serializer_const = Panko::SerializerResolver.resolve(serializer_const, self) end serializer_const ||= Panko::SerializerResolver.resolve(name.to_s, self) raise "Can't find serializer for #{self.name}.#{name} has_one relationship." if serializer_const.nil? @_descriptor.has_one_associations << Panko::Association.new( name, options.fetch(:name, name).to_s, Panko::SerializationDescriptor.build(serializer_const, options) ) end def has_many(name, options = {}) serializer_const = options[:serializer] || options[:each_serializer] if serializer_const.is_a?(String) serializer_const = Panko::SerializerResolver.resolve(serializer_const, self) end serializer_const ||= Panko::SerializerResolver.resolve(name.to_s, self) raise "Can't find serializer for #{self.name}.#{name} has_many relationship." if serializer_const.nil? @_descriptor.has_many_associations << Panko::Association.new( name, options.fetch(:name, name).to_s, Panko::SerializationDescriptor.build(serializer_const, options) ) end end def initialize(options = {}) # this "_skip_init" trick is so I can create serializers from serialization descriptor return if options[:_skip_init] @serialization_context = SerializationContext.create(options) @descriptor = Panko::SerializationDescriptor.build(self.class, options, @serialization_context) @used = false end def context @serialization_context.context end def scope @serialization_context.scope end attr_writer :serialization_context attr_reader :object def serialize(object) serialize_with_writer(object, Panko::ObjectWriter.new).output end def serialize_to_json(object) serialize_with_writer(object, Oj::StringWriter.new(mode: :rails)).to_s end private def serialize_with_writer(object, writer) raise ArgumentError.new("Panko::Serializer instances are single-use") if @used Panko.serialize_object(object, writer, @descriptor) @used = true writer end end end ================================================ FILE: lib/panko/serializer_resolver.rb ================================================ # frozen_string_literal: true require "active_support/core_ext/string/inflections" require "active_support/core_ext/module/introspection" class Panko::SerializerResolver class << self def resolve(name, from) serializer_const = nil namespace = namespace_for(from) if namespace.present? serializer_const = safe_serializer_get("#{namespace}::#{name.singularize.camelize}Serializer") end serializer_const ||= safe_serializer_get("#{name.singularize.camelize}Serializer") serializer_const ||= safe_serializer_get(name) serializer_const end private if Module.method_defined?(:module_parent_name) def namespace_for(from) from.module_parent_name end else def namespace_for(from) from.parent_name end end def safe_serializer_get(name) const = Object.const_get(name) (const < Panko::Serializer) ? const : nil rescue NameError nil end end end ================================================ FILE: lib/panko/version.rb ================================================ # frozen_string_literal: true module Panko VERSION = "0.8.5" end ================================================ FILE: lib/panko_serializer.rb ================================================ # frozen_string_literal: true require "panko/version" require "panko/attribute" require "panko/association" require "panko/serializer" require "panko/array_serializer" require "panko/response" require "panko/serializer_resolver" require "panko/object_writer" # C Extension require "oj" require "panko/panko_serializer" ================================================ FILE: panko_serializer.gemspec ================================================ # frozen_string_literal: true lib = File.expand_path("lib", __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "panko/version" Gem::Specification.new do |spec| spec.name = "panko_serializer" spec.version = Panko::VERSION spec.authors = ["Yosi Attias"] spec.email = ["yosy101@gmail.com"] spec.summary = "High Performance JSON Serialization for ActiveRecord & Ruby Objects" spec.homepage = "https://panko.dev" spec.license = "MIT" spec.metadata = { "bug_tracker_uri" => "https://github.com/yosiat/panko_serializer/issues", "source_code_uri" => "https://github.com/yosiat/panko_serializer", "documentation_uri" => "https://panko.dev", "changelog_uri" => "https://github.com/yosiat/panko_serializer/releases" } spec.required_ruby_version = ">= 3.1.0" spec.files = Dir["{ext,lib}/**/*", "LICENSE.txt", "README.md"] & `git ls-files -z`.split("\x0") spec.require_paths = ["lib"] spec.extensions << "ext/panko_serializer/extconf.rb" spec.add_dependency "oj", "> 3.11.0", "< 4.0.0" spec.add_dependency "activesupport" spec.add_development_dependency "appraisal" end ================================================ FILE: spec/features/active_record_serialization_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe "ActiveRecord Serialization" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end it "serializes objects from database" do class FooSerializer < Panko::Serializer attributes :name, :address end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word).reload expect(foo).to serialized_as(FooSerializer, "name" => foo.name, "address" => foo.address) end it "serializes objects from memory" do class FooSerializer < Panko::Serializer attributes :name, :address end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(FooSerializer, "name" => foo.name, "address" => foo.address) end it "preserves changed attributes" do class FooSerializer < Panko::Serializer attributes :name, :address end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word).reload foo.update!(name: "This is a new name") expect(foo).to serialized_as(FooSerializer, "name" => "This is a new name", "address" => foo.address) end end ================================================ FILE: spec/features/array_serializer_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe Panko::ArraySerializer do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end before { stub_const("FooSerializer", foo_serializer_class) } it "throws argument error when each_serializer isnt passed" do expect do Panko::ArraySerializer.new([]) end.to raise_error(ArgumentError) end context "sanity" do it "serializers array of elements" do array_serializer_factory = -> { Panko::ArraySerializer.new([], each_serializer: FooSerializer) } foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(Foo.all).to serialized_as(array_serializer_factory, [ {"name" => foo1.name, "address" => foo1.address}, {"name" => foo2.name, "address" => foo2.address} ]) end it "serializes array of elements with virtual attribtues" do class TestSerializerWithMethodsSerializer < Panko::Serializer attributes :name, :address, :something, :context_fetch def something "#{object.name} #{object.address}" end def context_fetch context[:value] end end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) array_serializer_factory = -> { Panko::ArraySerializer.new([], each_serializer: TestSerializerWithMethodsSerializer, context: {value: 6}) } expect(Foo.all).to serialized_as(array_serializer_factory, [{"name" => foo.name, "address" => foo.address, "something" => "#{foo.name} #{foo.address}", "context_fetch" => 6}]) end end context "filter" do it "only" do array_serializer_factory = -> { Panko::ArraySerializer.new([], each_serializer: FooSerializer, only: [:name]) } foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(Foo.all).to serialized_as(array_serializer_factory, [ {"name" => foo1.name}, {"name" => foo2.name} ]) end it "except" do array_serializer_factory = -> { Panko::ArraySerializer.new([], each_serializer: FooSerializer, except: [:name]) } foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(Foo.all).to serialized_as(array_serializer_factory, [ {"address" => foo1.address}, {"address" => foo2.address} ]) end end end ================================================ FILE: spec/features/associations_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe "Associations Serialization" do context "has_one" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end Temping.create(:foo_holder) do with_columns do |t| t.string :name t.references :foo end belongs_to :foo, optional: true end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end before { stub_const("FooSerializer", foo_serializer_class) } it "serializes plain object associations" do class PlainFooHolder attr_accessor :name, :foo def initialize(name, foo) @name = name @foo = foo end end class PlainFoo attr_accessor :name, :address def initialize(name, address) @name = name @address = address end end class FooSerializer < Panko::Serializer attributes :name, :address end class PlainFooHolderHasOneSerializer < Panko::Serializer attributes :name has_one :foo, serializer: FooSerializer end foo = PlainFoo.new(Faker::Lorem.word, Faker::Lorem.word) foo_holder = PlainFooHolder.new(Faker::Lorem.word, foo) expect(foo_holder).to serialized_as(PlainFooHolderHasOneSerializer, "name" => foo_holder.name, "foo" => { "name" => foo.name, "address" => foo.address }) end it "accepts serializer name as string" do class FooHolderHasOneWithStringSerializer < Panko::Serializer attributes :name has_one :foo, serializer: "FooSerializer" end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: foo) expect(foo_holder).to serialized_as(FooHolderHasOneWithStringSerializer, "name" => foo_holder.name, "foo" => { "name" => foo.name, "address" => foo.address }) end it "accepts name option" do class FooHolderHasOneWithNameSerializer < Panko::Serializer attributes :name has_one :foo, serializer: FooSerializer, name: :my_foo end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: foo) expect(foo_holder).to serialized_as(FooHolderHasOneWithNameSerializer, "name" => foo_holder.name, "my_foo" => { "name" => foo.name, "address" => foo.address }) end it "serializes using the :serializer option" do class FooHolderHasOneSerializer < Panko::Serializer attributes :name has_one :foo, serializer: FooSerializer end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: foo) expect(foo_holder).to serialized_as(FooHolderHasOneSerializer, "name" => foo_holder.name, "foo" => { "name" => foo.name, "address" => foo.address }) end it "infers the serializer name by name of the relationship" do class FooHolderHasOneSerializer < Panko::Serializer attributes :name has_one :foo end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: foo) expect(foo_holder).to serialized_as(FooHolderHasOneSerializer, "name" => foo_holder.name, "foo" => { "name" => foo.name, "address" => foo.address }) end it "raises if it can't find the serializer" do expect do class NotFoundHasOneSerializer < Panko::Serializer attributes :name has_one :not_existing_serializer end end.to raise_error("Can't find serializer for NotFoundHasOneSerializer.not_existing_serializer has_one relationship.") end it "allows virtual method in has one serializer" do class VirtualSerializer < Panko::Serializer attributes :virtual def virtual "Hello #{object.name}" end end class FooHolderHasOneVirtualSerializer < Panko::Serializer attributes :name has_one :foo, serializer: VirtualSerializer end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: foo) expect(foo_holder).to serialized_as(FooHolderHasOneVirtualSerializer, "name" => foo_holder.name, "foo" => { "virtual" => "Hello #{foo.name}" }) end it "handles nil" do class FooHolderHasOneSerializer < Panko::Serializer attributes :name has_one :foo, serializer: FooSerializer end foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: nil) expect(foo_holder).to serialized_as(FooHolderHasOneSerializer, "name" => foo_holder.name, "foo" => nil) end end context "has_one with different model types" do it "can use the serializer string name when resolving the serializer" do Temping.create(:goo) do with_columns do |t| t.string :name t.string :address end end Temping.create(:foo_holder) do with_columns do |t| t.string :name t.references :goo end belongs_to :goo end class FooSerializer < Panko::Serializer attributes :name, :address end class FooHolderHasOnePooWithStringSerializer < Panko::Serializer attributes :name has_one :goo, serializer: "FooSerializer" end goo = Goo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, goo: goo) expect(foo_holder).to serialized_as( FooHolderHasOnePooWithStringSerializer, "name" => foo_holder.name, "goo" => { "name" => goo.name, "address" => goo.address } ) end end context "has_many" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address t.references :foos_holder end belongs_to :foos_holder, optional: true end Temping.create(:foos_holder) do with_columns do |t| t.string :name end has_many :foos end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end before { stub_const("FooSerializer", foo_serializer_class) } it "serializes using the :serializer option" do class FoosHasManyHolderSerializer < Panko::Serializer attributes :name has_many :foos, serializer: FooSerializer end foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: [foo1, foo2]) expect(foos_holder).to serialized_as(FoosHasManyHolderSerializer, "name" => foos_holder.name, "foos" => [ { "name" => foo1.name, "address" => foo1.address }, { "name" => foo2.name, "address" => foo2.address } ]) end it "accepts serializer name as string" do class FoosHasManyHolderSerializer < Panko::Serializer attributes :name has_many :foos, serializer: "FooSerializer" end foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: [foo1, foo2]) expect(foos_holder).to serialized_as(FoosHasManyHolderSerializer, "name" => foos_holder.name, "foos" => [ { "name" => foo1.name, "address" => foo1.address }, { "name" => foo2.name, "address" => foo2.address } ]) end it "supports :name" do class FoosHasManyHolderWithNameSerializer < Panko::Serializer attributes :name has_many :foos, serializer: FooSerializer, name: :my_foos end foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: [foo1, foo2]) expect(foos_holder).to serialized_as(FoosHasManyHolderWithNameSerializer, "name" => foos_holder.name, "my_foos" => [ { "name" => foo1.name, "address" => foo1.address }, { "name" => foo2.name, "address" => foo2.address } ]) end it "infers the serializer name by name of the relationship" do class FoosHasManyHolderSerializer < Panko::Serializer attributes :name has_many :foos end foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: [foo1, foo2]) expect(foos_holder).to serialized_as(FoosHasManyHolderSerializer, "name" => foos_holder.name, "foos" => [ { "name" => foo1.name, "address" => foo1.address }, { "name" => foo2.name, "address" => foo2.address } ]) end it "raises if it can't find the serializer" do expect do class NotFoundHasManySerializer < Panko::Serializer attributes :name has_many :not_existing_serializers end end.to raise_error("Can't find serializer for NotFoundHasManySerializer.not_existing_serializers has_many relationship.") end it "serializes using the :each_serializer option" do class FoosHasManyHolderSerializer < Panko::Serializer attributes :name has_many :foos, each_serializer: FooSerializer end foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: [foo1, foo2]) expect(foos_holder).to serialized_as(FoosHasManyHolderSerializer, "name" => foos_holder.name, "foos" => [ { "name" => foo1.name, "address" => foo1.address }, { "name" => foo2.name, "address" => foo2.address } ]) end it "accepts only as option" do class FoosHolderWithOnlySerializer < Panko::Serializer attributes :name has_many :foos, serializer: FooSerializer, only: [:address] end foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: [foo1, foo2]) expect(foos_holder).to serialized_as(FoosHolderWithOnlySerializer, "name" => foos_holder.name, "foos" => [ { "address" => foo1.address }, { "address" => foo2.address } ]) end end context "has_many with different model types" do it "uses the serializer string name when resolving the serializer" do Temping.create(:goo) do with_columns do |t| t.string :name t.string :address t.references :foos_holder end belongs_to :foos_holder, optional: true end Temping.create(:foos_holder) do with_columns do |t| t.string :name end has_many :goos end class FooSerializer < Panko::Serializer attributes :name, :address end class FoosHasManyPoosHolderSerializer < Panko::Serializer attributes :name has_many :goos, serializer: "FooSerializer" end goo1 = Goo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) goo2 = Goo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, goos: [goo1, goo2]) expect(foos_holder).to serialized_as( FoosHasManyPoosHolderSerializer, "name" => foos_holder.name, "goos" => [ { "name" => goo1.name, "address" => goo1.address }, { "name" => goo2.name, "address" => goo2.address } ] ) end end context "polymorphic associations" do it "serializes polymorphic has_many associations" do Temping.create(:comment) do with_columns do |t| t.string :content t.references :commentable, polymorphic: true end belongs_to :commentable, polymorphic: true end Temping.create(:post) do with_columns do |t| t.string :title t.string :content end has_many :comments, as: :commentable end class CommentSerializer < Panko::Serializer attributes :content end class PostSerializer < Panko::Serializer attributes :title, :content has_many :comments, serializer: CommentSerializer end post = Post.create(title: Faker::Lorem.word, content: Faker::Lorem.sentence) comment1 = Comment.create(content: Faker::Lorem.sentence, commentable: post) comment2 = Comment.create(content: Faker::Lorem.sentence, commentable: post) expect(post).to serialized_as(PostSerializer, "title" => post.title, "content" => post.content, "comments" => [ {"content" => comment1.content}, {"content" => comment2.content} ]) end it "serializes polymorphic associations with different models" do Temping.create(:comment) do with_columns do |t| t.string :content t.references :commentable, polymorphic: true end belongs_to :commentable, polymorphic: true end Temping.create(:article) do with_columns do |t| t.string :title t.string :body end has_many :comments, as: :commentable end class CommentSerializer < Panko::Serializer attributes :content end class ArticleSerializer < Panko::Serializer attributes :title, :body has_many :comments, serializer: CommentSerializer end article = Article.create(title: Faker::Lorem.word, body: Faker::Lorem.sentence) comment = Comment.create(content: Faker::Lorem.sentence, commentable: article) expect(article).to serialized_as(ArticleSerializer, "title" => article.title, "body" => article.body, "comments" => [ {"content" => comment.content} ]) end end context "deeply nested associations" do it "serializes 2+ levels of nesting" do Temping.create(:user) do with_columns do |t| t.string :name t.string :email t.references :team end belongs_to :team end Temping.create(:team) do with_columns do |t| t.string :name t.references :organization end belongs_to :organization has_many :users end Temping.create(:organization) do with_columns do |t| t.string :name end has_many :teams has_many :users, through: :teams end class UserSerializer < Panko::Serializer attributes :name, :email end class TeamSerializer < Panko::Serializer attributes :name has_many :users, serializer: UserSerializer end class OrganizationSerializer < Panko::Serializer attributes :name has_many :teams, serializer: TeamSerializer end org = Organization.create(name: Faker::Company.name) team1 = Team.create(name: Faker::Team.name, organization: org) team2 = Team.create(name: Faker::Team.name, organization: org) user1 = User.create(name: Faker::Name.name, email: Faker::Internet.email, team: team1) user2 = User.create(name: Faker::Name.name, email: Faker::Internet.email, team: team2) expect(org).to serialized_as(OrganizationSerializer, "name" => org.name, "teams" => [ { "name" => team1.name, "users" => [ { "name" => user1.name, "email" => user1.email } ] }, { "name" => team2.name, "users" => [ { "name" => user2.name, "email" => user2.email } ] } ]) end end context "combined options" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address t.references :foos_holder end belongs_to :foos_holder, optional: true end Temping.create(:foos_holder) do with_columns do |t| t.string :name end has_many :foos end Temping.create(:foo_holder) do with_columns do |t| t.string :name t.references :foo end belongs_to :foo end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end before { stub_const("FooSerializer", foo_serializer_class) } it "handles multiple options together (name, serializer, only)" do class FoosHolderCombinedOptionsSerializer < Panko::Serializer attributes :name has_many :foos, serializer: FooSerializer, name: :my_items, only: [:name] end foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: [foo1, foo2]) expect(foos_holder).to serialized_as(FoosHolderCombinedOptionsSerializer, "name" => foos_holder.name, "my_items" => [ {"name" => foo1.name}, {"name" => foo2.name} ]) end it "handles has_one with multiple options" do class FooHolderCombinedOptionsSerializer < Panko::Serializer attributes :name has_one :foo, serializer: FooSerializer, name: :my_foo end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: foo) expect(foo_holder).to serialized_as(FooHolderCombinedOptionsSerializer, "name" => foo_holder.name, "my_foo" => { "name" => foo.name, "address" => foo.address }) end end context "nil and empty associations" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address t.references :foos_holder end belongs_to :foos_holder, optional: true end Temping.create(:foo_holder) do with_columns do |t| t.string :name t.references :foo end belongs_to :foo, optional: true end Temping.create(:foos_holder) do with_columns do |t| t.string :name end has_many :foos end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end before { stub_const("FooSerializer", foo_serializer_class) } it "explicitly handles has_one returning nil" do class FooHolderNilSerializer < Panko::Serializer attributes :name has_one :foo, serializer: FooSerializer end foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: nil) expect(foo_holder).to serialized_as(FooHolderNilSerializer, "name" => foo_holder.name, "foo" => nil) end it "explicitly handles has_many returning empty array" do class FoosHolderEmptySerializer < Panko::Serializer attributes :name has_many :foos, serializer: FooSerializer end foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: []) expect(foos_holder).to serialized_as(FoosHolderEmptySerializer, "name" => foos_holder.name, "foos" => []) end end context "invalid associations" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end Temping.create(:foo_holder) do with_columns do |t| t.string :name t.references :foo end belongs_to :foo end end it "handles when associated object is not of expected type" do # This test verifies graceful handling when an association returns an unexpected type class FlexibleSerializer < Panko::Serializer attributes :name, :address def name # Handle case where object might not respond to name object.respond_to?(:name) ? object.name : "unknown" end def address object.respond_to?(:address) ? object.address : "unknown" end end class FooHolderFlexibleSerializer < Panko::Serializer attributes :name has_one :foo, serializer: FlexibleSerializer end # Create a foo_holder with a regular foo foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: foo) expect(foo_holder).to serialized_as(FooHolderFlexibleSerializer, "name" => foo_holder.name, "foo" => { "name" => foo.name, "address" => foo.address }) end end end ================================================ FILE: spec/features/attributes_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe "Attributes Serialization" do context "instance variables" do it "serializes instance variables" do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end class FooSerializer < Panko::Serializer attributes :name, :address end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(FooSerializer, "name" => foo.name, "address" => foo.address) end end context "method attributes" do it "serializes method attributes" do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end class FooWithMethodsSerializer < Panko::Serializer attributes :name, :address, :something def something "#{object.name} #{object.address}" end def another_method raise "I shouldn't get called" end end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(FooWithMethodsSerializer, "name" => foo.name, "address" => foo.address, "something" => "#{foo.name} #{foo.address}") end end context "inheritance" do it "supports serializer inheritance" do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end class BaseSerializer < Panko::Serializer attributes :name end class ChildSerializer < BaseSerializer attributes :address end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(ChildSerializer, "name" => foo.name, "address" => foo.address) end end context "time serialization" do it "serializes time correctly" do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address t.timestamps end end class ObjectWithTimeSerializer < Panko::Serializer attributes :created_at, :method def method object.created_at end end obj = Foo.create expect(obj).to serialized_as(ObjectWithTimeSerializer, "created_at" => obj.created_at.as_json, "method" => obj.created_at.as_json) end end context "type handling" do before do Temping.create(:foo) do with_columns do |t| t.string :value end end end it "honors additional types" do class FooValueSerializer < Panko::Serializer attributes :value end foo = Foo.instantiate({"value" => "1"}, "value" => ActiveRecord::Type::Integer.new) expect(foo).to serialized_as(FooValueSerializer, "value" => 1) end end context "aliases" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end it "supports active record alias attributes" do class FooWithAliasesModel < ActiveRecord::Base self.table_name = "foos" alias_attribute :full_name, :name end class FooWithArAliasesSerializer < Panko::Serializer attributes :full_name, :address end foo = FooWithAliasesModel.create(full_name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(FooWithArAliasesSerializer, "full_name" => foo.name, "address" => foo.address) end it "allows to alias attributes" do class FooWithAliasesSerializer < Panko::Serializer attributes :address aliases name: :full_name end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(FooWithAliasesSerializer, "full_name" => foo.name, "address" => foo.address) end context "alias with method_fields" do let(:data) { {"created_at" => created_at} } let(:created_at) { "2023-04-18T09:24:41+00:00" } context "with alias" do let(:serializer_class) do Class.new(Panko::Serializer) do aliases({created_at: :createdAt}) end end it "has createdAt" do expect(data).to serialized_as(serializer_class, "createdAt" => created_at) end end context "with alias + method_fields" do let(:serializer_class) do Class.new(Panko::Serializer) do aliases({created_at: :createdAt}) def created_at "2023-04-18T09:24:41+00:00" end end end it "has createdAt" do expect(data).to serialized_as(serializer_class, "createdAt" => created_at) end end end end context "null values" do it "serializes null values" do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end class FooSerializer < Panko::Serializer attributes :name, :address end expect(Foo.create).to serialized_as(FooSerializer, "name" => nil, "address" => nil) end end context "SKIP functionality" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end it "can skip fields" do class FooSkipSerializer < FooSerializer def address object.address || SKIP end end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(FooSkipSerializer, "name" => foo.name, "address" => foo.address) foo = Foo.create(name: Faker::Lorem.word, address: nil) expect(foo).to serialized_as(FooSkipSerializer, "name" => foo.name) end end context "serializer reuse" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end it "raises an error when reusing serializer instances" do class FooSerializer < Panko::Serializer attributes :name, :address end serializer = FooSerializer.new foo_a = Foo.create foo_b = Foo.create expect { serializer.serialize(foo_a) }.not_to raise_error expect { serializer.serialize(foo_b) }.to raise_error(ArgumentError, "Panko::Serializer instances are single-use") end end end ================================================ FILE: spec/features/context_and_scope_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe "Context and Scope" do context "context" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end it "passes context to attribute methods" do class FooWithContextSerializer < Panko::Serializer attributes :name, :context_value def context_value context[:value] end end context = {value: Faker::Lorem.word} serializer_factory = -> { FooWithContextSerializer.new(context: context) } foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(serializer_factory, "name" => foo.name, "context_value" => context[:value]) end end context "scope" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end Temping.create(:foo_holder) do with_columns do |t| t.string :name t.references :foo end belongs_to :foo end end let(:foo_with_scope_serializer_class) do Class.new(Panko::Serializer) do attributes :scope_value def scope_value scope end end end let(:foo_holder_with_scope_serializer_class) do Class.new(Panko::Serializer) do attributes :scope_value has_one :foo, serializer: "FooWithScopeSerializer" def scope_value scope end end end before do stub_const("FooWithScopeSerializer", foo_with_scope_serializer_class) stub_const("FooHolderWithScopeSerializer", foo_holder_with_scope_serializer_class) end it "passes scope to attribute methods" do scope = 123 serializer_factory = -> { FooHolderWithScopeSerializer.new(scope: scope) } foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: foo) expect(foo_holder).to serialized_as(serializer_factory, "scope_value" => scope, "foo" => { "scope_value" => scope }) end it "default scope is nil" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo_holder = FooHolder.create(name: Faker::Lorem.word, foo: foo) expect(foo_holder).to serialized_as(FooHolderWithScopeSerializer, "scope_value" => nil, "foo" => { "scope_value" => nil }) end end end ================================================ FILE: spec/features/filtering_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe "Filtering Serialization" do context "basic filtering" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end before { stub_const("FooSerializer", foo_serializer_class) } it "supports only filter" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(-> { FooSerializer.new(only: [:name]) }, "name" => foo.name) end it "supports except filter" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(-> { FooSerializer.new(except: [:name]) }, "address" => foo.address) end end context "association filtering" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address t.references :foos_holder end belongs_to :foos_holder, optional: true end Temping.create(:foos_holder) do with_columns do |t| t.string :name end has_many :foos end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end before { stub_const("FooSerializer", foo_serializer_class) } it "filters associations" do class FoosHolderForFilterTestSerializer < Panko::Serializer attributes :name has_many :foos, serializer: FooSerializer end serializer_factory = -> { FoosHolderForFilterTestSerializer.new(only: [:foos]) } foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: [foo1, foo2]) expect(foos_holder).to serialized_as(serializer_factory, "foos" => [ { "name" => foo1.name, "address" => foo1.address }, { "name" => foo2.name, "address" => foo2.address } ]) end it "filters association attributes" do class FoosHolderForFilterTestSerializer < Panko::Serializer attributes :name has_many :foos, serializer: FooSerializer end serializer_factory = -> { FoosHolderForFilterTestSerializer.new(only: {foos: [:name]}) } foo1 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foo2 = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) foos_holder = FoosHolder.create(name: Faker::Lorem.word, foos: [foo1, foo2]) expect(foos_holder).to serialized_as(serializer_factory, "name" => foos_holder.name, "foos" => [ { "name" => foo1.name }, { "name" => foo2.name } ]) end end context "complex nested filters" do before do Temping.create(:user) do with_columns do |t| t.string :name t.string :email t.references :team end belongs_to :team end Temping.create(:team) do with_columns do |t| t.string :name t.references :organization end belongs_to :organization has_many :users end Temping.create(:organization) do with_columns do |t| t.string :name end has_many :teams has_many :users, through: :teams end end let(:user_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :email end end let(:team_serializer_class) do Class.new(Panko::Serializer) do attributes :name has_many :users, serializer: "UserSerializer" end end let(:organization_serializer_class) do Class.new(Panko::Serializer) do attributes :name has_many :teams, serializer: "TeamSerializer" end end before do stub_const("UserSerializer", user_serializer_class) stub_const("TeamSerializer", team_serializer_class) stub_const("OrganizationSerializer", organization_serializer_class) end it "supports complex nested only clauses on has_many" do org = Organization.create(name: Faker::Company.name) team = Team.create(name: Faker::Team.name, organization: org) user = User.create(name: Faker::Name.name, email: Faker::Internet.email, team: team) serializer_factory = -> { OrganizationSerializer.new(only: {teams: {users: [:email]}}) } expect(org).to serialized_as(serializer_factory, "name" => org.name, "teams" => [ { "name" => team.name, "users" => [ {"email" => user.email} ] } ]) end it "supports complex nested only clauses on has_one" do class TeamWithOrgSerializer < Panko::Serializer attributes :name has_one :organization, serializer: OrganizationSerializer end org = Organization.create(name: Faker::Company.name) team = Team.create(name: Faker::Team.name, organization: org) serializer_factory = -> { TeamWithOrgSerializer.new(only: {organization: [:name]}) } expect(team).to serialized_as(serializer_factory, "name" => team.name, "organization" => { "name" => org.name }) end end context "aliased attribute filtering" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end it "filters based on aliased attribute name" do class FooWithAliasFilterSerializer < Panko::Serializer attributes :address aliases name: :full_name end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) # Filter using the alias name expect(foo).to serialized_as(-> { FooWithAliasFilterSerializer.new(only: [:full_name]) }, "full_name" => foo.name) # Filter using except should also work expect(foo).to serialized_as(-> { FooWithAliasFilterSerializer.new(except: [:address]) }, "full_name" => foo.name) end end context "filter interactions" do before do Temping.create(:user) do with_columns do |t| t.string :name t.string :email t.references :team end belongs_to :team end Temping.create(:team) do with_columns do |t| t.string :name end has_many :users end end let(:user_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :email end end before { stub_const("UserSerializer", user_serializer_class) } it "tests interaction between ArraySerializer filters and has_many association filters" do class TeamArrayFilterSerializer < Panko::Serializer attributes :name has_many :users, serializer: UserSerializer end team = Team.create(name: Faker::Team.name) user1 = User.create(name: Faker::Name.name, email: Faker::Internet.email, team: team) user2 = User.create(name: Faker::Name.name, email: Faker::Internet.email, team: team) # ArraySerializer filter should work with association filters array_serializer = Panko::ArraySerializer.new([team], each_serializer: TeamArrayFilterSerializer, only: {users: [:name]}) result = array_serializer.to_json expected = [ { "name" => team.name, "users" => [ {"name" => user1.name}, {"name" => user2.name} ] } ] expect(JSON.parse(result)).to eq(expected) end end context "nested except filters" do before do Temping.create(:user) do with_columns do |t| t.string :name t.string :email t.references :team end belongs_to :team end Temping.create(:team) do with_columns do |t| t.string :name end has_many :users end end let(:user_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :email end end before { stub_const("UserSerializer", user_serializer_class) } it "supports nested except filters" do class TeamWithExceptSerializer < Panko::Serializer attributes :name has_many :users, serializer: UserSerializer end team = Team.create(name: Faker::Team.name) user = User.create(name: Faker::Name.name, email: Faker::Internet.email, team: team) serializer_factory = -> { TeamWithExceptSerializer.new(except: {users: [:email]}) } expect(team).to serialized_as(serializer_factory, "name" => team.name, "users" => [ {"name" => user.name} ]) end end context "only vs except precedence" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end before { stub_const("FooSerializer", foo_serializer_class) } it "only takes precedence over except when both are provided" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(-> { FooSerializer.new(only: [:name], except: [:address]) }, "name" => foo.name) end end context "filters_for interaction" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end it "combines filters_for with constructor only/except options" do class FooWithFiltersForSerializer < Panko::Serializer attributes :name, :address def self.filters_for(context, scope) {except: [:address]} end end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) # filters_for except should be combined with constructor only expect(foo).to serialized_as(-> { FooWithFiltersForSerializer.new(only: [:name]) }, "name" => foo.name) end it "passes context and scope to filters_for method" do class FooWithContextFiltersSerializer < Panko::Serializer attributes :name, :address def self.filters_for(context, scope) if context[:user_role] == "admin" {only: [:name, :address]} else {only: [:name]} end end end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) # Test with admin context expect(foo).to serialized_as(-> { FooWithContextFiltersSerializer.new(context: {user_role: "admin"}) }, "name" => foo.name, "address" => foo.address) # Test with regular user context expect(foo).to serialized_as(-> { FooWithContextFiltersSerializer.new(context: {user_role: "user"}) }, "name" => foo.name) end end context "deeply nested filters" do before do Temping.create(:user) do with_columns do |t| t.string :name t.string :email t.references :team end belongs_to :team end Temping.create(:team) do with_columns do |t| t.string :name t.references :organization end belongs_to :organization has_many :users end Temping.create(:organization) do with_columns do |t| t.string :name end has_many :teams has_many :users, through: :teams end end let(:user_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :email end end let(:team_serializer_class) do Class.new(Panko::Serializer) do attributes :name has_many :users, serializer: "UserSerializer" end end let(:organization_serializer_class) do Class.new(Panko::Serializer) do attributes :name has_many :teams, serializer: "TeamSerializer" end end before do stub_const("UserSerializer", user_serializer_class) stub_const("TeamSerializer", team_serializer_class) stub_const("OrganizationSerializer", organization_serializer_class) end it "filters on 2+ levels deep associations" do org = Organization.create(name: Faker::Company.name) team = Team.create(name: Faker::Team.name, organization: org) user = User.create(name: Faker::Name.name, email: Faker::Internet.email, team: team) serializer_factory = -> { OrganizationSerializer.new(only: {teams: {users: [:name]}}) } expect(org).to serialized_as(serializer_factory, "name" => org.name, "teams" => [ { "name" => team.name, "users" => [ {"name" => user.name} ] } ]) end it "supports mixed only and except at different nesting levels" do org = Organization.create(name: Faker::Company.name) team = Team.create(name: Faker::Team.name, organization: org) user = User.create(name: Faker::Name.name, email: Faker::Internet.email, team: team) # Use only at top level, except at nested level serializer_factory = -> { OrganizationSerializer.new(only: [:teams], except: {teams: {users: [:email]}}) } expect(org).to serialized_as(serializer_factory, "teams" => [ { "name" => team.name, "users" => [ {"name" => user.name} ] } ]) end end context "invalid filter values" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end before { stub_const("FooSerializer", foo_serializer_class) } it "raises error for non-Array/Hash only filters" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect do FooSerializer.new(only: "invalid").serialize(foo) end.to raise_error(NoMethodError) # TODO: change the error to be ArgumentError end it "raises error for non-Array/Hash except filters" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect do FooSerializer.new(except: 123).serialize(foo) end.to raise_error(NoMethodError) # TODO: change the error to be ArgumentError end it "handles filters on non-existent attributes" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) # Should not raise error, just ignore non-existent attributes expect(foo).to serialized_as(-> { FooSerializer.new(only: [:name, :non_existent]) }, "name" => foo.name) end it "handles filters on non-existent associations" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) serializer_factory = -> { FooSerializer.new(only: {non_existent_association: [:name]}) } expect(foo).to serialized_as(serializer_factory, "name" => foo.name, "address" => foo.address) end end context "filters_for method" do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end it "fetches the filters from the serializer" do class FooWithFiltersForSerializer < Panko::Serializer attributes :name, :address def self.filters_for(context, scope) {only: [:name]} end end foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) expect(foo).to serialized_as(-> { FooWithFiltersForSerializer.new }, "name" => foo.name) end end end ================================================ FILE: spec/features/hash_serialization_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe "Hash Serialization" do class FooSerializer < Panko::Serializer attributes :name, :address end it "serializes hash with string keys" do foo = { "name" => Faker::Lorem.word, "address" => Faker::Lorem.word } expect(foo).to serialized_as(FooSerializer, "name" => foo["name"], "address" => foo["address"]) end it "serializes HashWithIndifferentAccess with symbol keys" do foo = ActiveSupport::HashWithIndifferentAccess.new( name: Faker::Lorem.word, address: Faker::Lorem.word ) expect(foo).to serialized_as(FooSerializer, "name" => foo["name"], "address" => foo["address"]) end it "serializes HashWithIndifferentAccess with string keys" do foo = ActiveSupport::HashWithIndifferentAccess.new( "name" => Faker::Lorem.word, "address" => Faker::Lorem.word ) expect(foo).to serialized_as(FooSerializer, "name" => foo["name"], "address" => foo["address"]) end end ================================================ FILE: spec/features/poro_serialization_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe "PORO Serialization" do class FooSerializer < Panko::Serializer attributes :name, :address end it "serializes plain objects" do class PlainFoo attr_accessor :name, :address def initialize(name, address) @name = name @address = address end end foo = PlainFoo.new(Faker::Lorem.word, Faker::Lorem.word) expect(foo).to serialized_as(FooSerializer, "name" => foo.name, "address" => foo.address) end end ================================================ FILE: spec/spec_helper.rb ================================================ # frozen_string_literal: true require "bundler/setup" require "logger" require "panko_serializer" require "faker" require "active_record" require "temping" # Load database configuration helper require_relative "support/database_config" # Require database adapters based on environment case DatabaseConfig.database_type when "sqlite" require "sqlite3" when "postgresql" require "pg" when "mysql" require "trilogy" end # Set up database connection DatabaseConfig.setup_database ActiveRecord::Base.establish_connection(DatabaseConfig.config) # Don't show migration output ActiveRecord::Migration.verbose = false RSpec.configure do |config| config.order = "random" # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" config.filter_run focus: true config.run_all_when_everything_filtered = true config.full_backtrace = ENV.fetch("CI", false) if ENV.fetch("CI", false) config.before(:example, :focus) do raise "This example was committed with `:focus` and should not have been" end end config.expect_with :rspec do |c| c.syntax = :expect end config.after do Temping.teardown end end RSpec::Matchers.define :serialized_as do |serializer_factory_or_class, output| serializer_factory = if serializer_factory_or_class.respond_to?(:call) serializer_factory_or_class else -> { serializer_factory_or_class.new } end match do |object| expect(serializer_factory.call.serialize(object)).to eq(output) json = Oj.load serializer_factory.call.serialize_to_json(object) expect(json).to eq(output) end failure_message do |object| <<~FAILURE Expected Output: #{output} Got: Object: #{serializer_factory.call.serialize(object)} JSON: #{Oj.load(serializer_factory.call.serialize_to_json(object))} FAILURE end end if GC.respond_to?(:verify_compaction_references) # This method was added in Ruby 3.0.0. Calling it this way asks the GC to # move objects around, helping to find object movement bugs. GC.verify_compaction_references(double_heap: true, toward: :empty) end ================================================ FILE: spec/support/database_config.rb ================================================ # frozen_string_literal: true # Database configuration helper for tests class DatabaseConfig ADAPTERS = { "sqlite" => { adapter: "sqlite3", database: ":memory:" }, "postgresql" => { adapter: "postgresql", database: "panko_test", host: ENV["POSTGRES_HOST"] || "localhost", username: ENV["POSTGRES_USER"] || "postgres", password: ENV["POSTGRES_PASSWORD"] || "", port: ENV["POSTGRES_PORT"] || 5432 }, "mysql" => { adapter: "trilogy", database: "panko_test", host: ENV["MYSQL_HOST"] || "localhost", username: ENV["MYSQL_USER"] || "root", password: ENV["MYSQL_PASSWORD"] || "", port: ENV["MYSQL_PORT"] || 3306 } }.freeze def self.database_type ENV["DB"] || "sqlite" end def self.config adapter_config = ADAPTERS[database_type] raise "Unsupported database type: #{database_type}. Supported: #{ADAPTERS.keys.join(", ")}" unless adapter_config adapter_config end def self.setup_database # For CI and local development, we assume databases are already created # SQLite uses in-memory database which needs no setup # PostgreSQL and MySQL databases should be created externally puts "Using #{database_type} database: #{config[:database]}" if ENV["DEBUG"] end def self.teardown_database # For SQLite in-memory, no teardown needed # For persistent databases, we rely on test transaction rollbacks # rather than dropping/recreating the database for performance end end ================================================ FILE: spec/unit/panko/array_serializer_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe Panko::ArraySerializer do describe "#initialize" do it "raises ArgumentError when each_serializer is not provided" do expect do Panko::ArraySerializer.new([]) end.to raise_error(ArgumentError, /Please pass valid each_serializer/) end it "accepts each_serializer option" do mock_serializer = Class.new(Panko::Serializer) expect do Panko::ArraySerializer.new([], each_serializer: mock_serializer) end.not_to raise_error end it "stores subjects" do mock_serializer = Class.new(Panko::Serializer) subjects = [1, 2, 3] array_serializer = Panko::ArraySerializer.new(subjects, each_serializer: mock_serializer) expect(array_serializer.subjects).to eq(subjects) end it "builds serialization context from options" do mock_serializer = Class.new(Panko::Serializer) context = {user_id: 123} scope = "admin" array_serializer = Panko::ArraySerializer.new([], each_serializer: mock_serializer, context: context, scope: scope) serialization_context = array_serializer.instance_variable_get(:@serialization_context) expect(serialization_context.context).to eq(context) expect(serialization_context.scope).to eq(scope) end it "passes filtering options to descriptor" do mock_serializer = Class.new(Panko::Serializer) # Mock SerializationDescriptor.build to verify options are passed expect(Panko::SerializationDescriptor).to receive(:build).with( mock_serializer, hash_including( only: [:name], except: [:email], context: {user_id: 123}, scope: "admin" ), anything ) Panko::ArraySerializer.new([], each_serializer: mock_serializer, only: [:name], except: [:email], context: {user_id: 123}, scope: "admin") end it "defaults only and except to empty arrays when not provided" do mock_serializer = Class.new(Panko::Serializer) expect(Panko::SerializationDescriptor).to receive(:build).with( mock_serializer, hash_including( only: [], except: [] ), anything ) Panko::ArraySerializer.new([], each_serializer: mock_serializer) end end describe "option handling" do let(:mock_serializer) { Class.new(Panko::Serializer) } it "handles only option" do array_serializer = Panko::ArraySerializer.new([], each_serializer: mock_serializer, only: [:name, :email]) # Verify that the descriptor was built with the only option descriptor = array_serializer.instance_variable_get(:@descriptor) expect(descriptor).not_to be_nil end it "handles except option" do array_serializer = Panko::ArraySerializer.new([], each_serializer: mock_serializer, except: [:password, :secret]) descriptor = array_serializer.instance_variable_get(:@descriptor) expect(descriptor).not_to be_nil end it "handles context option" do context = {current_user: "admin"} array_serializer = Panko::ArraySerializer.new([], each_serializer: mock_serializer, context: context) serialization_context = array_serializer.instance_variable_get(:@serialization_context) expect(serialization_context.context).to eq(context) end it "handles scope option" do scope = "public" array_serializer = Panko::ArraySerializer.new([], each_serializer: mock_serializer, scope: scope) serialization_context = array_serializer.instance_variable_get(:@serialization_context) expect(serialization_context.scope).to eq(scope) end end describe "serialization methods" do let(:mock_serializer) { Class.new(Panko::Serializer) } let(:subjects) { [double("obj1"), double("obj2")] } let(:array_serializer) { Panko::ArraySerializer.new(subjects, each_serializer: mock_serializer) } describe "#serialize" do it "calls Panko.serialize_objects with correct parameters" do mock_writer = double("writer", output: []) allow(Panko::ObjectWriter).to receive(:new).and_return(mock_writer) expect(Panko).to receive(:serialize_objects).with( subjects, mock_writer, array_serializer.instance_variable_get(:@descriptor) ) array_serializer.serialize(subjects) end end describe "#to_a" do it "calls serialize_with_writer with stored subjects" do mock_writer = double("writer", output: []) allow(Panko::ObjectWriter).to receive(:new).and_return(mock_writer) expect(Panko).to receive(:serialize_objects).with( subjects, mock_writer, anything ) array_serializer.to_a end end describe "#serialize_to_json" do it "uses Oj::StringWriter for JSON output" do mock_writer = double("writer", to_s: "[]") allow(Oj::StringWriter).to receive(:new).with(mode: :rails).and_return(mock_writer) allow(Panko).to receive(:serialize_objects) result = array_serializer.serialize_to_json(subjects) expect(result).to eq("[]") end end describe "#to_json" do it "calls serialize_to_json with stored subjects" do expect(array_serializer).to receive(:serialize_to_json).with(subjects) array_serializer.to_json end end end end ================================================ FILE: spec/unit/panko/object_writer_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" require "active_record/connection_adapters/postgresql_adapter" describe Panko::ObjectWriter do let(:writer) { Panko::ObjectWriter.new } context "push_object" do it "property" do writer.push_object writer.push_value "yosi", "name" writer.pop expect(writer.output).to eql("name" => "yosi") end it "property with separate key value instructions" do writer.push_object writer.push_key "name" writer.push_value "yosi" writer.pop expect(writer.output).to eql("name" => "yosi") end it "supports nested objects" do writer.push_object writer.push_value "yosi", "name" writer.push_object("nested") writer.push_value "key1", "value" writer.pop writer.pop expect(writer.output).to eql( "name" => "yosi", "nested" => { "value" => "key1" } ) end it "supports nested arrays" do writer.push_object writer.push_value "yosi", "name" writer.push_object("nested") writer.push_value "key1", "value" writer.pop writer.push_array "values" writer.push_object writer.push_value "item", "key" writer.pop writer.pop writer.pop expect(writer.output).to eql( "name" => "yosi", "nested" => { "value" => "key1" }, "values" => [ {"key" => "item"} ] ) end end it "supports arrays" do writer.push_array writer.push_object writer.push_value "key1", "value" writer.pop writer.push_object writer.push_value "key2", "value2" writer.pop writer.pop expect(writer.output).to eql([ { "value" => "key1" }, { "value2" => "key2" } ]) end end ================================================ FILE: spec/unit/panko/response_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe Panko::Response do before do Temping.create(:foo) do with_columns do |t| t.string :name t.string :address end end end let(:foo_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :address end end let(:foo_with_context_serializer_class) do Class.new(Panko::Serializer) do attributes :name, :context_value def context_value context[:value] end end end before do stub_const("FooSerializer", foo_serializer_class) stub_const("FooWithContextSerializer", foo_with_context_serializer_class) end it "serializes primitive values" do response = Panko::Response.new(success: true, num: 1) json_response = Oj.load(response.to_json) expect(json_response["success"]).to eq(true) expect(json_response["num"]).to eq(1) end it "serializes hash values" do hash = {"a" => 1, "b" => 2} response = Panko::Response.new(success: true, hash: hash) json_response = Oj.load(response.to_json) expect(json_response["hash"]).to eq(hash) end it "serializes json wrapped in json value" do response = Panko::Response.new(success: true, value: Panko::JsonValue.from('{"a":1}')) json_response = Oj.load(response.to_json) expect(json_response["success"]).to eq(true) expect(json_response["value"]).to eq("a" => 1) end it "serializes array serializer" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) response = Panko::Response.new(success: true, foos: Panko::ArraySerializer.new(Foo.all, each_serializer: FooSerializer)) json_response = Oj.load(response.to_json) expect(json_response["success"]).to eq(true) expect(json_response["foos"]).to eq([ "name" => foo.name, "address" => foo.address ]) end it "supports nesting of responses" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) response = Panko::Response.new( data: Panko::Response.new( data: Panko::Response.new( rows: [ Panko::Response.new( foos: Panko::ArraySerializer.new(Foo.all, each_serializer: FooSerializer) ) ] ) ) ) json_response = Oj.load(response.to_json) expect(json_response).to eq( "data" => { "data" => { "rows" => [ "foos" => [{ "name" => foo.name, "address" => foo.address }] ] } } ) end it "supports array" do response = Panko::Response.new([ data: Panko::Response.new( json_data: Panko::JsonValue.from({a: 1}.to_json) ) ]) json_response = Oj.load(response.to_json) expect(json_response).to eql([ {"data" => {"json_data" => {"a" => 1}}} ]) end it "create" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) response = Panko::Response.create do |t| [ { data: t.value( json_data: t.json({a: 1}.to_json), foos: t.array_serializer(Foo.all, FooSerializer), foo: t.serializer(Foo.first, FooSerializer) ) } ] end json_response = Oj.load(response.to_json) expect(json_response).to eql([ {"data" => { "json_data" => {"a" => 1}, "foo" => { "name" => foo.name, "address" => foo.address }, "foos" => [{ "name" => foo.name, "address" => foo.address }] }} ]) end it "create with context" do foo = Foo.create(name: Faker::Lorem.word, address: Faker::Lorem.word) context = {value: Faker::Lorem.word} response = Panko::Response.create do |t| [ { data: t.value( foos: t.array_serializer(Foo.all, FooWithContextSerializer, context: context), foo: t.serializer(Foo.first, FooWithContextSerializer, context: context) ) } ] end json_response = Oj.load(response.to_json) expect(json_response).to eql([ {"data" => { "foo" => { "name" => foo.name, "context_value" => context[:value] }, "foos" => [{ "name" => foo.name, "context_value" => context[:value] }] }} ]) end end ================================================ FILE: spec/unit/panko/serialization_descriptor_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe Panko::SerializationDescriptor do class FooSerializer < Panko::Serializer attributes :name, :address end context "attributes" do it "simple fields" do descriptor = Panko::SerializationDescriptor.build(FooSerializer) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([ Panko::Attribute.create(:name), Panko::Attribute.create(:address) ]) end it "method attributes" do class SerializerWithMethodsSerializer < Panko::Serializer attributes :name, :address, :something def something "#{object.name} #{object.address}" end end descriptor = Panko::SerializationDescriptor.build(SerializerWithMethodsSerializer) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([ Panko::Attribute.create(:name), Panko::Attribute.create(:address) ]) expect(descriptor.method_fields).to eq([:something]) end it "aliases" do class AttribteAliasesSerializer < Panko::Serializer aliases name: :full_name end descriptor = Panko::SerializationDescriptor.build(AttribteAliasesSerializer) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([ Panko::Attribute.create(:name, alias_name: :full_name) ]) end it "allows multiple filters in other runs" do class MultipleFiltersTestSerializer < Panko::Serializer attributes :name, :address has_many :foos, each_serializer: FooSerializer end descriptor = Panko::SerializationDescriptor.build(MultipleFiltersTestSerializer, only: { instance: [:foos], foos: [:name] }) expect(descriptor.has_many_associations.first.descriptor.attributes).to eq([ Panko::Attribute.create(:name) ]) descriptor = Panko::SerializationDescriptor.build(MultipleFiltersTestSerializer) expect(descriptor.has_many_associations.first.descriptor.attributes).to eq([ Panko::Attribute.create(:name), Panko::Attribute.create(:address) ]) end end context "associations" do it "has_one: build_descriptor" do class BuilderTestFooHolderHasOneSerializer < Panko::Serializer attributes :name has_one :foo, serializer: FooSerializer end descriptor = Panko::SerializationDescriptor.build(BuilderTestFooHolderHasOneSerializer) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([Panko::Attribute.create(:name)]) expect(descriptor.method_fields).to be_empty expect(descriptor.has_one_associations.count).to eq(1) foo_association = descriptor.has_one_associations.first expect(foo_association.name_sym).to eq(:foo) foo_descriptor = Panko::SerializationDescriptor.build(FooSerializer, {}) expect(foo_association.descriptor.attributes).to eq(foo_descriptor.attributes) expect(foo_association.descriptor.method_fields).to eq(foo_descriptor.method_fields) end it "has_many: builds descriptor" do class BuilderTestFoosHasManyHolderSerializer < Panko::Serializer attributes :name has_many :foos, serializer: FooSerializer end descriptor = Panko::SerializationDescriptor.build(BuilderTestFoosHasManyHolderSerializer) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([Panko::Attribute.create(:name)]) expect(descriptor.method_fields).to be_empty expect(descriptor.has_one_associations).to be_empty expect(descriptor.has_many_associations.count).to eq(1) foo_association = descriptor.has_many_associations.first expect(foo_association.name_sym).to eq(:foos) foo_descriptor = Panko::SerializationDescriptor.build(FooSerializer, {}) expect(foo_association.descriptor.attributes).to eq(foo_descriptor.attributes) expect(foo_association.descriptor.method_fields).to eq(foo_descriptor.method_fields) end end context "filter" do it "only" do descriptor = Panko::SerializationDescriptor.build(FooSerializer, only: [:name]) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([Panko::Attribute.create(:name)]) expect(descriptor.method_fields).to be_empty end it "except" do descriptor = Panko::SerializationDescriptor.build(FooSerializer, except: [:name]) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([Panko::Attribute.create(:address)]) expect(descriptor.method_fields).to be_empty end it "except filters aliases" do class ExceptFooWithAliasesSerializer < Panko::Serializer aliases name: :full_name, address: :full_address end descriptor = Panko::SerializationDescriptor.build(ExceptFooWithAliasesSerializer, except: [:full_name]) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([Panko::Attribute.create(:address, alias_name: :full_address)]) end it "only filters aliases" do class OnlyFooWithAliasesSerializer < Panko::Serializer attributes :address aliases name: :full_name end descriptor = Panko::SerializationDescriptor.build(OnlyFooWithAliasesSerializer, only: [:full_name]) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([Panko::Attribute.create(:name, alias_name: :full_name)]) end it "only - filters aliases and fields" do class OnlyWithFieldsFooWithAliasesSerializer < Panko::Serializer attributes :address, :another_field aliases name: :full_name end descriptor = Panko::SerializationDescriptor.build(OnlyWithFieldsFooWithAliasesSerializer, only: %i[full_name address]) expect(descriptor).not_to be_nil expect(descriptor.attributes).to eq([ Panko::Attribute.create(:address), Panko::Attribute.create(:name, alias_name: :full_name) ]) end it "filters associations" do class FooHasOneSerilizers < Panko::Serializer attributes :name has_one :foo1, serializer: FooSerializer has_one :foo2, serializer: FooSerializer end descriptor = Panko::SerializationDescriptor.build(FooHasOneSerilizers, only: %i[name foo1]) expect(descriptor.attributes).to eq([Panko::Attribute.create(:name)]) expect(descriptor.has_one_associations.count).to eq(1) foos_assoc = descriptor.has_one_associations.first expect(foos_assoc.name_sym).to eq(:foo1) expect(foos_assoc.descriptor.attributes).to eq([Panko::Attribute.create(:name), Panko::Attribute.create(:address)]) end describe "association filters" do it "accepts only as option" do class AssocFilterTestFoosHolderSerializer < Panko::Serializer attributes :name has_many :foos, serializer: FooSerializer end descriptor = Panko::SerializationDescriptor.build(AssocFilterTestFoosHolderSerializer, only: {foos: [:address]}) expect(descriptor.attributes).to eq([Panko::Attribute.create(:name)]) expect(descriptor.has_many_associations.count).to eq(1) foos_assoc = descriptor.has_many_associations.first expect(foos_assoc.name_sym).to eq(:foos) expect(foos_assoc.descriptor.attributes).to eq([Panko::Attribute.create(:address)]) end end end end ================================================ FILE: spec/unit/panko/serializer_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe Panko::Serializer do describe "class methods" do describe ".inherited" do it "creates a new descriptor for the child class" do base_class = Class.new(Panko::Serializer) child_class = Class.new(base_class) expect(child_class._descriptor).not_to be_nil expect(child_class._descriptor).not_to eq(base_class._descriptor) expect(child_class._descriptor.type).to eq(child_class) end it "duplicates parent descriptor when inheriting" do base_class = Class.new(Panko::Serializer) do attributes :name end child_class = Class.new(base_class) expect(child_class._descriptor.attributes.map(&:name)).to include("name") expect(child_class._descriptor).not_to equal(base_class._descriptor) end it "initializes empty collections for new serializers" do serializer_class = Class.new(Panko::Serializer) descriptor = serializer_class._descriptor expect(descriptor.attributes).to eq([]) expect(descriptor.aliases).to eq({}) expect(descriptor.method_fields).to eq([]) expect(descriptor.has_many_associations).to eq([]) expect(descriptor.has_one_associations).to eq([]) end end describe ".attributes" do it "adds attributes to the descriptor" do serializer_class = Class.new(Panko::Serializer) do attributes :name, :email end attribute_names = serializer_class._descriptor.attributes.map(&:name) expect(attribute_names).to include("name", "email") end it "ensures uniqueness of attributes" do serializer_class = Class.new(Panko::Serializer) do attributes :name, :email, :name end attribute_names = serializer_class._descriptor.attributes.map(&:name) expect(attribute_names.count("name")).to eq(1) end end describe ".aliases" do it "adds aliased attributes to the descriptor" do serializer_class = Class.new(Panko::Serializer) do aliases name: :full_name, email: :email_address end attributes = serializer_class._descriptor.attributes name_attr = attributes.find { |attr| attr.name == "name" } email_attr = attributes.find { |attr| attr.name == "email" } expect(name_attr.alias_name).to eq("full_name") expect(email_attr.alias_name).to eq("email_address") end end describe ".method_added" do it "moves attribute to method_fields when method is defined" do serializer_class = Class.new(Panko::Serializer) do attributes :name, :computed_field def computed_field "computed" end end regular_attributes = serializer_class._descriptor.attributes.map(&:name) method_fields = serializer_class._descriptor.method_fields.map(&:name) expect(regular_attributes).to include("name") expect(regular_attributes).not_to include("computed_field") expect(method_fields).to include("computed_field") end it "preserves alias_name when moving to method_fields" do serializer_class = Class.new(Panko::Serializer) do aliases computed_field: :computed_alias def computed_field "computed" end end method_field = serializer_class._descriptor.method_fields.find { |attr| attr.name == "computed_field" } expect(method_field.alias_name).to eq("computed_alias") end end describe ".has_one" do it "raises error when serializer cannot be found" do expect do Class.new(Panko::Serializer) do has_one :nonexistent_relation end end.to raise_error(/Can't find serializer/) end end describe ".has_many" do it "raises error when serializer cannot be found" do expect do Class.new(Panko::Serializer) do has_many :nonexistent_relations end end.to raise_error(/Can't find serializer/) end end end describe "instance methods" do let(:serializer_class) do Class.new(Panko::Serializer) do attributes :name end end describe "#initialize" do it "creates serialization context from options" do context = {user_id: 123} scope = "admin" serializer = serializer_class.new(context: context, scope: scope) expect(serializer.context).to eq(context) expect(serializer.scope).to eq(scope) end it "builds descriptor with options and context" do serializer = serializer_class.new(only: [:name]) expect(serializer.instance_variable_get(:@descriptor)).not_to be_nil expect(serializer.instance_variable_get(:@used)).to eq(false) end it "skips initialization when _skip_init is true" do serializer = serializer_class.new(_skip_init: true) expect(serializer.instance_variable_get(:@descriptor)).to be_nil expect(serializer.instance_variable_get(:@used)).to be_nil end end describe "#context and #scope" do it "returns nil for context and scope when not provided" do serializer = serializer_class.new expect(serializer.context).to be_nil expect(serializer.scope).to be_nil end it "returns provided context and scope" do context = {user_id: 123} scope = "admin" serializer = serializer_class.new(context: context, scope: scope) expect(serializer.context).to eq(context) expect(serializer.scope).to eq(scope) end end describe "single-use enforcement" do it "raises error on second use" do serializer = serializer_class.new mock_object = double("object") # Mock the Panko.serialize_object call to avoid dependencies allow(Panko).to receive(:serialize_object) # First call should work expect { serializer.serialize(mock_object) }.not_to raise_error # Second call should raise error expect { serializer.serialize(mock_object) }.to raise_error(ArgumentError, "Panko::Serializer instances are single-use") end end end end ================================================ FILE: spec/unit/serializer_resolver_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe Panko::SerializerResolver do it "resolves serializer on singular name" do class CoolSerializer < Panko::Serializer end result = Panko::SerializerResolver.resolve("cool", Object) expect(result._descriptor).to be_a(Panko::SerializationDescriptor) expect(result._descriptor.type).to eq(CoolSerializer) end it "resolves serializer on plural name" do class PersonSerializer < Panko::Serializer end result = Panko::SerializerResolver.resolve("persons", Object) expect(result._descriptor).to be_a(Panko::SerializationDescriptor) expect(result._descriptor.type).to eq(PersonSerializer) end it "resolves serializer on multiple-word name" do class MyCoolSerializer < Panko::Serializer end result = Panko::SerializerResolver.resolve("my_cool", Object) expect(result._descriptor).to be_a(Panko::SerializationDescriptor) expect(result._descriptor.type).to eq(MyCoolSerializer) end it "resolves serializer in namespace first" do class CoolSerializer < Panko::Serializer end module MyApp class CoolSerializer < Panko::Serializer end class PersonSerializer < Panko::Serializer end end result = Panko::SerializerResolver.resolve("cool", MyApp::PersonSerializer) expect(result._descriptor).to be_a(Panko::SerializationDescriptor) expect(result._descriptor.type).to eq(MyApp::CoolSerializer) result = Panko::SerializerResolver.resolve("cool", Panko) expect(result._descriptor).to be_a(Panko::SerializationDescriptor) expect(result._descriptor.type).to eq(CoolSerializer) end describe "errors cases" do it "returns nil when the serializer name can't be found" do expect(Panko::SerializerResolver.resolve("nonexistent_model", Object)).to be_nil end it "returns nil when the serializer is not Panko::Serializer" do class SomeObjectSerializer end expect(Panko::SerializerResolver.resolve("some_object", Object)).to be_nil end end end ================================================ FILE: spec/unit/type_cast_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" require "active_record/connection_adapters/postgresql_adapter" def check_if_exists(module_name) mod = (begin module_name.constantize rescue nil end) return true if mod false unless mod end describe "Type Casting" do describe "String / Text" do context "ActiveRecord::Type::String" do let(:type) { ActiveRecord::Type::String.new } it { expect(Panko._type_cast(type, true)).to eq("t") } it { expect(Panko._type_cast(type, nil)).to be_nil } it { expect(Panko._type_cast(type, false)).to eq("f") } it { expect(Panko._type_cast(type, 123)).to eq("123") } it { expect(Panko._type_cast(type, "hello world")).to eq("hello world") } end context "ActiveRecord::Type::Text" do let(:type) { ActiveRecord::Type::Text.new } it { expect(Panko._type_cast(type, true)).to eq("t") } it { expect(Panko._type_cast(type, false)).to eq("f") } it { expect(Panko._type_cast(type, 123)).to eq("123") } it { expect(Panko._type_cast(type, "hello world")).to eq("hello world") } end # We treat uuid as stirng, there is no need for type cast before serialization context "ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Uuid" do let(:type) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Uuid.new } it { expect(Panko._type_cast(type, "e67d284b-87b8-445e-a20d-3c76ea353866")).to eq("e67d284b-87b8-445e-a20d-3c76ea353866") } end end describe "Integer" do context "ActiveRecord::Type::Integer" do let(:type) { ActiveRecord::Type::Integer.new } it { expect(Panko._type_cast(type, "")).to be_nil } it { expect(Panko._type_cast(type, nil)).to be_nil } it { expect(Panko._type_cast(type, 1)).to eq(1) } it { expect(Panko._type_cast(type, "1")).to eq(1) } it { expect(Panko._type_cast(type, 1.7)).to eq(1) } it { expect(Panko._type_cast(type, true)).to eq(1) } it { expect(Panko._type_cast(type, false)).to eq(0) } it { expect(Panko._type_cast(type, [6])).to be_nil } it { expect(Panko._type_cast(type, six: 6)).to be_nil } end context "ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Integer", if: check_if_exists("ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Integer") do let(:type) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Integer.new } it { expect(Panko._type_cast(type, "")).to be_nil } it { expect(Panko._type_cast(type, nil)).to be_nil } it { expect(Panko._type_cast(type, 1)).to eq(1) } it { expect(Panko._type_cast(type, "1")).to eq(1) } it { expect(Panko._type_cast(type, 1.7)).to eq(1) } it { expect(Panko._type_cast(type, true)).to eq(1) } it { expect(Panko._type_cast(type, false)).to eq(0) } it { expect(Panko._type_cast(type, [6])).to be_nil } it { expect(Panko._type_cast(type, six: 6)).to be_nil } end end context "ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Json", if: check_if_exists("ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Json") do let(:type) { ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Json.new } it { expect(Panko._type_cast(type, "")).to be_nil } it { expect(Panko._type_cast(type, "shnitzel")).to be_nil } it { expect(Panko._type_cast(type, nil)).to be_nil } it { expect(Panko._type_cast(type, '{"a":1}')).to eq('{"a":1}') } it { expect(Panko._type_cast(type, "[6,12]")).to eq("[6,12]") } it { expect(Panko._type_cast(type, "a" => 1)).to eq("a" => 1) } it { expect(Panko._type_cast(type, [6, 12])).to eq([6, 12]) } end context "ActiveRecord::Type::Json", if: check_if_exists("ActiveRecord::Type::Json") do let(:type) { ActiveRecord::Type::Json.new } it { expect(Panko._type_cast(type, "")).to be_nil } it { expect(Panko._type_cast(type, "shnitzel")).to be_nil } it { expect(Panko._type_cast(type, nil)).to be_nil } it { expect(Panko._type_cast(type, '{"a":1}')).to eq('{"a":1}') } it { expect(Panko._type_cast(type, "[6,12]")).to eq("[6,12]") } it { expect(Panko._type_cast(type, "a" => 1)).to eq("a" => 1) } it { expect(Panko._type_cast(type, [6, 12])).to eq([6, 12]) } end context "ActiveRecord::Type::Boolean" do let(:type) { ActiveRecord::Type::Boolean.new } it { expect(Panko._type_cast(type, "")).to be_nil } it { expect(Panko._type_cast(type, nil)).to be_nil } it { expect(Panko._type_cast(type, true)).to be_truthy } it { expect(Panko._type_cast(type, 1)).to be_truthy } it { expect(Panko._type_cast(type, "1")).to be_truthy } it { expect(Panko._type_cast(type, "t")).to be_truthy } it { expect(Panko._type_cast(type, "T")).to be_truthy } it { expect(Panko._type_cast(type, "true")).to be_truthy } it { expect(Panko._type_cast(type, "TRUE")).to be_truthy } it { expect(Panko._type_cast(type, false)).to be_falsey } it { expect(Panko._type_cast(type, 0)).to be_falsey } it { expect(Panko._type_cast(type, "0")).to be_falsey } it { expect(Panko._type_cast(type, "f")).to be_falsey } it { expect(Panko._type_cast(type, "F")).to be_falsey } it { expect(Panko._type_cast(type, "false")).to be_falsey } it { expect(Panko._type_cast(type, "FALSE")).to be_falsey } end context "Time" do let(:type) { ActiveRecord::Type::DateTime.new } let(:date) { DateTime.new(2017, 3, 4, 12, 45, 23) } let(:utc) { ActiveSupport::TimeZone.new("UTC") } it "ISO8601 strings" do expect(Panko._type_cast(type, date.in_time_zone(utc).as_json)).to eq("2017-03-04T12:45:23.000Z") end it "two digits after ." do expect(Panko._type_cast(type, "2018-09-16 14:51:03.97")).to eq("2018-09-16T14:51:03.970Z") end it "converts string from datbase to utc time zone" do time = "2017-07-10 09:26:40.937392" seconds = 40 + Rational(937_296, 10**6) result = DateTime.new(2017, 7, 10, 9, 26, seconds).in_time_zone(utc) expect(Panko._type_cast(type, time)).to eq(result.as_json) end end end