├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .standard.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── lib ├── protobuf-activerecord.rb └── protobuf │ └── active_record │ ├── attribute_methods.rb │ ├── columns.rb │ ├── config.rb │ ├── errors.rb │ ├── middleware │ ├── connection_management.rb │ ├── connection_management_async.rb │ └── query_cache.rb │ ├── model.rb │ ├── nested_attributes.rb │ ├── persistence.rb │ ├── railtie.rb │ ├── scope.rb │ ├── serialization.rb │ ├── transformation.rb │ ├── transformer.rb │ ├── validations.rb │ └── version.rb ├── protobuf-activerecord.gemspec └── spec ├── protobuf └── active_record │ ├── columns_spec.rb │ ├── nested_attributes_spec.rb │ ├── persistence_spec.rb │ ├── scope_spec.rb │ ├── serialization_spec.rb │ ├── transformation_spec.rb │ └── transformer_spec.rb ├── spec_helper.rb └── support ├── db.rb ├── db └── setup.rb ├── definitions └── messages.proto ├── models.rb ├── models ├── photo.rb └── user.rb ├── protobuf └── messages.pb.rb └── time_helpers.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 2 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 3 | name: build 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: "*" 10 | 11 | jobs: 12 | lint: 13 | name: Lint (Standard) 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: "2.7" 20 | bundler-cache: true 21 | - run: bundle exec standardrb --format github 22 | test: 23 | name: Specs 24 | runs-on: ubuntu-latest 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | ruby: [2.7, head, jruby-9.4, jruby-head] 29 | continue-on-error: ${{ endsWith(matrix.ruby, 'head') }} 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby }} 35 | - run: bundle install 36 | - run: bundle exec rake spec 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /Gemfile.lock 2 | /.DS_Store 3 | /.bundle/ 4 | /.ruby-*/ 5 | /.yardoc 6 | /_yardoc/ 7 | /coverage/ 8 | /doc/ 9 | /pkg/ 10 | /spec/reports/ 11 | /spec/test.db 12 | /spec/test.db-* 13 | /tmp/ 14 | 15 | # rspec failure tracking 16 | .rspec_status 17 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --require rspec/pride 3 | --format PrideFormatter 4 | --order rand 5 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit_gem: 3 | standard: config/ruby-2.7.yml 4 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/standardrb/standard 3 | format: progress # default: Standard::Formatter 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | All notable changes to this project will be documented in this file. 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 4 | 5 | Protobuf Active Record adheres to a shifted version of [semver](https://semver.org/spec/v2.0.0.html) 6 | (a la Rails): major/minor versions shadow Rails [versions](https://guides.rubyonrails.org/maintenance_policy.html#versioning) 7 | since it depends on specific Rails versions. 8 | 9 | ## [Unreleased] 10 | 11 | ## [7.0.0] – 2024-03-03 12 | 13 | - Added Rails 7.0 support 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in protobuf-activerecord.gemspec 4 | gemspec 5 | 6 | platforms :jruby do 7 | gem "activerecord-jdbcsqlite3-adapter" 8 | end 9 | 10 | platforms :ruby do 11 | gem "sqlite3", ">= 1.4" 12 | end 13 | 14 | gem "benchmark-ips" 15 | 16 | gem "rake", "~> 13.0" 17 | 18 | gem "rspec", "~> 3.0" 19 | gem "rspec-pride", ">= 3.1.0" 20 | 21 | gem "simplecov" 22 | 23 | gem "standard", "~> 1.3" 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Adam Hutchison 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/liveh2o/protobuf-activerecord/actions/workflows/main.yml/badge.svg)](https://github.com/liveh2o/protobuf-activerecord/actions) 2 | [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/standardrb/standard) 3 | [![Gem Version](https://badge.fury.io/rb/protobuf-activerecord.svg)](http://badge.fury.io/rb/protobuf-activerecord) 4 | 5 | # Protobuf ActiveRecord 6 | 7 | Protobuf Active Record provides the ability to create and update Active Record objects from protobuf messages and to serialize Active Record objects to protobuf messages. 8 | 9 | ## Installation 10 | 11 | Add this line to your application's Gemfile: 12 | 13 | gem 'protobuf-activerecord' 14 | 15 | And then execute: 16 | 17 | $ bundle 18 | 19 | Or install it yourself as: 20 | 21 | $ gem install protobuf-activerecord 22 | 23 | ## Usage 24 | 25 | Protobuf Active Record is automatically available in any Active Record model. Once installed, you can pass protobuf messages to your user model just like you would attributes. It will take care of converting the protobuf message to attributes and continue on with Active Record's normal behavior. 26 | 27 | ### Field/Attribute mapping 28 | 29 | Just like Active Record maps database columns to your model's attributes, Protobuf Active Record maps protobuf fields to your model's attributes. 30 | 31 | Given a table that looks like this: 32 | 33 | ```Ruby 34 | create_table :users do |t| 35 | t.string :first_name 36 | t.string :last_name 37 | t.string :email 38 | t.integer :account_id 39 | 40 | t.timestamps 41 | end 42 | ``` 43 | 44 | and a protobuf message that looks like this: 45 | 46 | ```Ruby 47 | class UserMessage < ::Protobuf::Message 48 | optional ::Protobuf::Field::StringField, :first_name, 1 49 | optional ::Protobuf::Field::StringField, :last_name, 2 50 | optional ::Protobuf::Field::StringField, :email, 3 51 | optional ::Protobuf::Field::IntegerField, :account_id, 4 52 | end 53 | ``` 54 | 55 | Protobuf Active Record will map the `first_name`, `last_name`, `email`, & `account_id` columns, skipping the timestamp columns. Repeated fields and fields that are nil will not be mapped. 56 | 57 | **Dates** 58 | 59 | Since Protocol Buffer messages don't support sending date, time, or datetime fields, Protobuf Active Record expects date, time, and datetime fields to be sent as integers. Just like Active Record handles translating Ruby dates, times, and datetimes into the proper database column types, Protobuf Active Record will handle converting dates, times, and dateimes to and from integers mapping protobuf message fields. 60 | 61 | Picking up our users table example again, if you wanted to add a `created_at` field to your protobuf message, if you add it as an integer field, Protobuf Active Record will handle the conversions for you: 62 | 63 | ```Ruby 64 | class UserMessage < ::Protobuf::Message 65 | optional ::Protobuf::Field::StringField, :first_name, 1 66 | optional ::Protobuf::Field::StringField, :last_name, 2 67 | optional ::Protobuf::Field::StringField, :email, 3 68 | optional ::Protobuf::Field::IntegerField, :account_id, 4 69 | 70 | # Add a datetime field as an integer and Protobuf Active Record will map it for you 71 | optional ::Protobuf::Field::IntegerField, :created_at, 5 72 | end 73 | ``` 74 | 75 | ### Creating/Updating 76 | 77 | Protobuf Active Record doesn't alter Active Record's normal persistence methods. It simply adds to ability to pass protobuf messages to them in place of an attributes hash. 78 | 79 | ### Serialization to protobuf 80 | 81 | In addition to mapping protobuf message fields to Active Record objects when creating or updating records, Active Record objects can also be serialized to protobuf messages. Simply specify the protobuf message that should be used and Protobuf Active Record will take care of the rest: 82 | 83 | ```Ruby 84 | class User < ActiveRecord::Base 85 | # Configures Protobuf Active Record to use the UserMessage class and adds :to_proto. 86 | protobuf_message :user_message 87 | end 88 | ``` 89 | 90 | Once the desired protobuf message has been specified, a `to_proto` method will be added to the model. Calling `to_proto` will automatically convert the model to the specified protobuf message using the same attribute to field mapping it uses to create and update objects from protobuf messages. 91 | 92 | ### Choosing serializable fields 93 | 94 | Protobuf Active Record also provides a mechanism for choosing which fields should be included when serializing objects to protobuf messages by passing additional options to `protobuf_message`: 95 | 96 | ```Ruby 97 | class User < ActiveRecord::Base 98 | # Passing :only => ... configures Protobuf Active Record to only serialize the given fields 99 | protobuf_message :user_message, :only => [ :first_name, :last_name, :email ] 100 | end 101 | ``` 102 | 103 | This will only include the first_name, last_name, and email fields. 104 | 105 | Conversely, the `:except` option allows the fields that should be excluded to be specified. 106 | 107 | ```Ruby 108 | class User < ActiveRecord::Base 109 | # Passing :except => ... configures Protobuf Active Record to serialize everything except the given fields 110 | protobuf_message :user_message, :except => [ :account_id, :created_at ] 111 | end 112 | ``` 113 | 114 | This does pretty much the same thing, but from a different perspective. 115 | 116 | `to_proto` also accepts these options, so you can override the class-level serializable fields on a per-instance basis: 117 | 118 | ```Ruby 119 | user.to_proto(:only => :email) # Only the email 120 | user.to_proto(:except => :email) # Everything except the email 121 | user.to_proto(:include => :email) # Start with the class-level settings, but add email 122 | ``` 123 | 124 | ### Serializing deprecated fields 125 | 126 | By default, deprecated fields are included when mapping protobuf message to Active Record objects. To exclude deprecated fields, simply pass the `:deprecated` option: 127 | 128 | ```Ruby 129 | class User < ActiveRecord::Base 130 | # Passing :deprecated => false configures Protobuf Active Record to exclude deprecated fields. 131 | protobuf_message :user_message, :deprecated => false 132 | end 133 | ``` 134 | 135 | On the chance you want to exclude deprecated fields, but also include a specfic one, simply pass the `:include` option: 136 | 137 | ```Ruby 138 | class User < ActiveRecord::Base 139 | # Passing :deprecated => false configures Protobuf Active Record to exclude deprecated fields. 140 | protobuf_message :user_message, :deprecated => false, :include => :a_specific_deprecated_field 141 | end 142 | ``` 143 | 144 | ### Field transformers 145 | 146 | Field transformers are used when serializing objects to protobuf messages. Regular field mapping and conversions will be handled out of the box, but for those times when fields don't map directly to attributes or custom behavior is needed, use `field_from_record` method. 147 | 148 | `field_from_record` takes the name of the field being transformed and a method name or callable (lambda or proc). When transforming that field, it calls the given callable, passing it the object being serialized. 149 | 150 | **Converting attributes** 151 | 152 | ```Ruby 153 | class User < ActiveRecord::Base 154 | # Calls the lambda when serializing objects to protobuf messages, passing it 155 | # the object being serialized. 156 | field_from_record :status, lambda { |object_being_serlialized| # Some custom behavior } 157 | end 158 | ``` 159 | 160 | ### Attribute transformers 161 | 162 | Protobuf Active Record handles mapping protobuf message fields to object attributes, but what happens when an attribute doesn't have a matching field? Using the `attribute_from_proto` method, you can define custom attribute transformations. Simply call `attribute_from_proto`, passing it the name of the attribute and a method name or callable (lambda or proc). When creating or updating objects, the transformer will be called and passed the protobuf message. 163 | 164 | ```Ruby 165 | class User < ActiveRecord::Base 166 | # Calls the lambda when creating/updating objects, passing it the protobuf 167 | # message. 168 | attribute_from_proto :account_id, lambda { |protobuf_message| # Some custom transformation... } 169 | end 170 | ``` 171 | 172 | ### Setting attributes to nil 173 | 174 | The protocol buffers specification does not allow for the transport of 'null' or 'nil' values for a field. In fact, in order to keep messages small and lightweight this is desireable behavior. Fields are that are not set to a value will not be sent over the wire, but we cannot assume given a message has an absent value for a field that we should set the our attributes to nil. 175 | 176 | In order to solve this problem, Protobuf::ActiveRecord has a convention that tells it when to set an attribute to nil. A message must define a repeated string field named 'nullify'. If an attribute has the same name as an element in the 'nullify' field, this attribute will be set to nil. 177 | 178 | Example: 179 | 180 | ``` 181 | message UserMessage { 182 | optional string name = 1; 183 | repeated string nullify = 2; 184 | } 185 | 186 | ``` 187 | 188 | ```ruby 189 | m = UserMessage.new(:nullify => [:name]) 190 | # When Protobuf::ActiveRecord maps this message, it will set the name attribute to nil overwriting any value that is set. 191 | ``` 192 | 193 | For attribute transformers, the field name will not match the attribute name so we need to give the attribute transformer a hint to instruct it on how to nullify a given attribute. When declaring an attribute transformer, you can specify a :nullify_on option. This indicates for the given attribute, if the value of 'nullify_on' is present in the nullify field, set this attribute to nil. 194 | 195 | Example: 196 | 197 | ```Ruby 198 | class User < ActiveRecord::Base 199 | # When 'account_guid' is present in the nullify array, our 'account_id' attribute will be set to nil 200 | attribute_from_proto :account_id, :nullify_on => :account_guid do 201 | # transform 202 | end 203 | end 204 | ``` 205 | 206 | #### Searching 207 | 208 | Protobuf Active Record's `search_scope` method takes the protobuf message and builds ARel scopes from it. 209 | 210 | Before you can use `search_scope`, you'll need to tell Protobuf Active Record which fields should be searchable and what scope should be used to search with that field. 211 | 212 | Consider this protobuf message: 213 | 214 | ``` 215 | message UserSearchRequest { 216 | repeated string guid = 1; 217 | repeated string name = 2; 218 | repeated string email = 3; 219 | } 220 | ``` 221 | 222 | To make the `name` field searchable, use the `field_scope` method: 223 | 224 | ```Ruby 225 | class User < ActiveRecord::Base 226 | scope :by_name, lambda { |*values| where(:name => values) } 227 | 228 | field_scope :name, :scope => :by_name 229 | end 230 | ``` 231 | 232 | This tells Protobuf Active Record that the name field should be searchable and that the :scope with the given name should be used to build the search scope. 233 | 234 | `field_scope` can also be called with just a field name: 235 | 236 | ```Ruby 237 | class User < ActiveRecord::Base 238 | scope :by_name, lambda { |*values| where(:name => values) } 239 | 240 | field_scope :name 241 | end 242 | ``` 243 | 244 | If no scope is given, Protobuf Active Record assumes that a scope matching the given field prefixed with `by_`, in this case `by_name`. 245 | 246 | Now that your class is configured with some searchable fields, you can use the `search_scope` method to build ARel scopes from a protobuf message. 247 | 248 | `search_scope` is chainable just like regular ARel scopes. It takes a protobuf messages and will build search scopes from any searchable fields that have values. 249 | 250 | Picking up our User class again: 251 | 252 | ```Ruby 253 | # Build a search scope from the given protobuf message 254 | User.search_scope(request) 255 | 256 | # It's chainable too 257 | User.limit(10).search_scope(request) 258 | ``` 259 | 260 | Protobuf Active Record also provides some aliases for the `search_scope` method in the event that you'd like something a little more descriptive: `by_fields` and `scope_from_proto` are all aliases of `search_scope`. 261 | 262 | #### Upsert 263 | 264 | Protobuf Active Record provides the ability to create a new record, or update an existing record if an existing record is found. This is implemented using Active Record's `first_or_initialize` method. 265 | 266 | ```Ruby 267 | class User < ActiveRecord::Base 268 | scope :by_guid, lambda { |*values| where(:guid => values) } 269 | scope :by_first_name, lambda { |*values| where(:first_name => values) } 270 | scope :by_last_name, lambda { |*values| where(:last_name => values) } 271 | 272 | field_scope :guid 273 | field_scope :first_name 274 | field_scope :last_name 275 | 276 | upsert_key :guid 277 | upsert_key :first_name, :last_name 278 | end 279 | 280 | @user = User.for_upsert(request) 281 | ``` 282 | 283 | Note: An upsert_key should only be defined on a field or set of fields that have a unique constraint 284 | 285 | Note: All fields used in an upsert key must also have a field_scope defined 286 | 287 | If multiple upsert_keys match the request, the first matching upsert key will be used, in order of declaration. In the typical use-case where upsert keys have corresponding unique constraints the results should be equivalent regardless of order. 288 | 289 | Protobuf Active Record provides several methods for invoking an upsert. 290 | 291 | The first approach is to use the `for_upsert` method to look up a record. 292 | 293 | ```Ruby 294 | @user = User.for_upsert(proto) 295 | ``` 296 | 297 | Alternatively, you can use `upsert` to look up the record and perform the persistence in the same call. 298 | 299 | ```Ruby 300 | # Example 301 | User.upsert(proto) 302 | 303 | # This is equivalent to 304 | user = User.for_upsert(proto) 305 | user.assign_attributes(proto) 306 | user.save 307 | ``` 308 | 309 | ## Contributing 310 | 311 | 1. Fork it 312 | 2. Create your feature branch (`git checkout -b my-new-feature`) 313 | 3. Commit your changes (`git commit -am 'Add some feature'`) 314 | 4. Push to the branch (`git push origin my-new-feature`) 315 | 5. Create new Pull Request 316 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | require "rspec/core/rake_task" 6 | 7 | desc "Run specs" 8 | RSpec::Core::RakeTask.new(:spec) do |t| 9 | t.ruby_opts = %w[-w] 10 | end 11 | 12 | require "standard/rake" 13 | 14 | desc "Run cops and specs (default)" 15 | task default: %i[spec standard:fix] 16 | 17 | desc "Remove protobuf definitions that have been compiled" 18 | task :clean do 19 | FileUtils.rm(Dir.glob("spec/support/protobuf/**/*.proto")) 20 | puts "Cleaned" 21 | end 22 | 23 | require "protobuf/tasks" 24 | 25 | desc "Compile spec/support protobuf definitions" 26 | task :compile, [] => :clean do 27 | Rake::Task["protobuf:compile"].invoke("", "spec/support/definitions", "spec/support/protobuf") 28 | end 29 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "protobuf-activerecord" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | require "irb" 10 | IRB.start(__FILE__) 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/protobuf-activerecord.rb: -------------------------------------------------------------------------------- 1 | # rubocop:disable Naming/FileName 2 | require "active_record" 3 | require "active_support/concern" 4 | require "heredity" 5 | require "protobuf" 6 | 7 | require "protobuf/active_record/config" 8 | require "protobuf/active_record/middleware/connection_management" 9 | require "protobuf/active_record/middleware/connection_management_async" 10 | require "protobuf/active_record/middleware/query_cache" 11 | require "protobuf/active_record/model" 12 | require "protobuf/active_record/version" 13 | 14 | module Protobuf 15 | module ActiveRecord 16 | def self.config 17 | @config ||= Protobuf::ActiveRecord::Config.new 18 | end 19 | 20 | # Initialize the config 21 | config 22 | end 23 | end 24 | 25 | require "protobuf/active_record/railtie" if defined?(Rails) 26 | # rubocop:enable Naming/FileName 27 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/attribute_methods.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | 3 | module Protobuf 4 | module ActiveRecord 5 | module AttributeMethods 6 | extend ActiveSupport::Concern 7 | 8 | module ClassMethods 9 | def alias_field(field_alias, attribute) 10 | alias_attribute field_alias, attribute 11 | 12 | attribute_from_proto attribute, fetch_attribute_alias_from_proto(attribute, field_alias) 13 | field_from_record field_alias, fetch_field_alias_from_record(attribute, field_alias) 14 | end 15 | 16 | def fetch_attribute_alias_from_proto(attribute, field_alias) 17 | lambda do |proto| 18 | value = proto.__send__(:"#{field_alias}!") 19 | value ||= proto.__send__(:"#{attribute}!") if proto.respond_to?(attribute) 20 | 21 | _protobuf_convert_fields_to_attributes(attribute, value) 22 | end 23 | end 24 | 25 | def fetch_field_alias_from_record(attribute, _field_aliasd) 26 | lambda do |record| 27 | value = record.__send__(field_alias) 28 | 29 | _protobuf_convert_attributes_to_fields(attribute, value) 30 | end 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/columns.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | 3 | module Protobuf 4 | module ActiveRecord 5 | module Columns 6 | extend ::ActiveSupport::Concern 7 | 8 | COLUMN_TYPE_MAP_MUTEX = ::Mutex.new 9 | DATE_OR_TIME_TYPES = ::Set.new([:date, :datetime, :time, :timestamp]) 10 | 11 | included do 12 | include ::Heredity 13 | 14 | inheritable_attributes :_protobuf_columns, 15 | :_protobuf_column_types, 16 | :_protobuf_date_datetime_time_or_timestamp_column, 17 | :_protobuf_mapped_columns 18 | end 19 | 20 | module ClassMethods 21 | def _protobuf_columns 22 | _protobuf_map_columns unless _protobuf_mapped_columns? 23 | 24 | @_protobuf_columns 25 | end 26 | 27 | def _protobuf_column_types 28 | _protobuf_map_columns unless _protobuf_mapped_columns? 29 | 30 | @_protobuf_column_types 31 | end 32 | 33 | def _protobuf_date_datetime_time_or_timestamp_column 34 | _protobuf_map_columns unless _protobuf_mapped_columns? 35 | 36 | @_protobuf_date_datetime_time_or_timestamp_column 37 | end 38 | 39 | # :nodoc: 40 | def _protobuf_date_column?(key) 41 | _protobuf_column_types[:date].include?(key) 42 | end 43 | 44 | # :nodoc: 45 | def _protobuf_date_datetime_time_or_timestamp_column?(key) 46 | _protobuf_date_datetime_time_or_timestamp_column.include?(key) 47 | end 48 | 49 | # :nodoc: 50 | def _protobuf_datetime_column?(key) 51 | _protobuf_column_types[:datetime].include?(key) 52 | end 53 | 54 | # Map out the columns for future reference on type conversion 55 | # :nodoc: 56 | def _protobuf_map_columns(force = false) 57 | COLUMN_TYPE_MAP_MUTEX.synchronize do 58 | @_protobuf_mapped_columns = false if force 59 | 60 | return unless table_exists? 61 | return if _protobuf_mapped_columns? 62 | 63 | @_protobuf_columns = {} 64 | @_protobuf_column_types = ::Hash.new { |h, k| h[k] = ::Set.new } 65 | @_protobuf_date_datetime_time_or_timestamp_column = ::Set.new 66 | 67 | columns.map do |column| 68 | column_name_symbol = column.name.to_sym 69 | column_type_symbol = column.type.to_sym 70 | @_protobuf_columns[column_name_symbol] = column 71 | @_protobuf_column_types[column_type_symbol] << column_name_symbol 72 | 73 | if DATE_OR_TIME_TYPES.include?(column_type_symbol) 74 | @_protobuf_date_datetime_time_or_timestamp_column << column_name_symbol 75 | end 76 | end 77 | 78 | @_protobuf_mapped_columns = true 79 | end 80 | end 81 | 82 | def _protobuf_mapped_columns? 83 | @_protobuf_mapped_columns 84 | end 85 | 86 | # :nodoc: 87 | def _protobuf_time_column?(key) 88 | _protobuf_column_types[:time].include?(key) 89 | end 90 | 91 | # :nodoc: 92 | def _protobuf_timestamp_column?(key) 93 | _protobuf_column_types[:timestamp].include?(key) 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/config.rb: -------------------------------------------------------------------------------- 1 | module Protobuf 2 | module ActiveRecord 3 | class Config < ActiveSupport::OrderedOptions 4 | def initialize(options = {}) 5 | super 6 | 7 | self[:autoload] = true 8 | self[:connection_reaping_interval] = 6 9 | self[:connection_reaping_timeout_interval] = 5 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/errors.rb: -------------------------------------------------------------------------------- 1 | module Protobuf 2 | module ActiveRecord 3 | # = Protobuf Active Record errors 4 | # 5 | # Generic Protobuf Active Record exception class 6 | class ProtobufActiveRecordError < StandardError 7 | end 8 | 9 | # Raised by `attribute_from_proto` when the transformer method 10 | # given is not callable. 11 | class AttributeTransformerError < ProtobufActiveRecordError 12 | def message 13 | "Attribute transformers must be called with a callable or block!" 14 | end 15 | end 16 | 17 | # Raised by `field_from_record` when the convert method 18 | # given not callable. 19 | class FieldTransformerError < ProtobufActiveRecordError 20 | def message 21 | "Field transformers must be called with a callable or block!" 22 | end 23 | end 24 | 25 | # Raised by `to_proto` when no protobuf message is defined. 26 | class MessageNotDefined < ProtobufActiveRecordError 27 | attr_reader :class_name 28 | 29 | def initialize(klass) 30 | @class_name = klass.name 31 | end 32 | 33 | def message 34 | "#{class_name} does not define a protobuf message" 35 | end 36 | end 37 | 38 | # Raised by `field_scope` when given scope is not defined. 39 | class SearchScopeError < ProtobufActiveRecordError 40 | end 41 | 42 | # Raised by `upsert_scope` when a given scope is not defined 43 | class UpsertScopeError < ProtobufActiveRecordError 44 | end 45 | 46 | # Raised by `for_upsert` when no valid upsert_scopes are found 47 | class UpsertNotFoundError < ProtobufActiveRecordError 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/middleware/connection_management.rb: -------------------------------------------------------------------------------- 1 | module Protobuf 2 | module ActiveRecord 3 | module Middleware 4 | class ConnectionManagement 5 | def initialize(app) 6 | @app = app 7 | end 8 | 9 | def call(env) 10 | @app.call(env) 11 | ensure 12 | ::ActiveRecord::Base.clear_active_connections! 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/middleware/connection_management_async.rb: -------------------------------------------------------------------------------- 1 | require "concurrent" 2 | 3 | module Protobuf 4 | module ActiveRecord 5 | module Middleware 6 | class ConnectionManagementAsync 7 | START_MUTEX = ::Mutex.new 8 | 9 | def self.start_timed_task! 10 | if timed_task_started.false? 11 | START_MUTEX.synchronize do 12 | return if timed_task_started.true? 13 | 14 | args = { 15 | execution_interval: ::Protobuf::ActiveRecord.config.connection_reaping_interval, 16 | timeout_interval: ::Protobuf::ActiveRecord.config.connection_reaping_timeout_interval 17 | } 18 | timed_task = ::Concurrent::TimerTask.new(args) do 19 | ::ActiveRecord::Base.clear_active_connections! 20 | end 21 | 22 | timed_task.execute 23 | timed_task_started.make_true 24 | end 25 | end 26 | end 27 | 28 | def self.timed_task_started 29 | if @timed_task_started.nil? 30 | @timed_task_started = ::Concurrent::AtomicBoolean.new(false) 31 | end 32 | 33 | @timed_task_started 34 | end 35 | 36 | def initialize(app) 37 | @app = app 38 | end 39 | 40 | # rubocop:disable Lint/DuplicateMethods 41 | # rubocop:disable Lint/NestedMethodDefinition 42 | def call(env) 43 | def call(env) 44 | ::ActiveRecord::Base.connection_pool.with_connection do 45 | @app.call(env) 46 | end 47 | end 48 | 49 | self.class.start_timed_task! 50 | call(env) 51 | end 52 | # rubocop:enable Lint/NestedMethodDefinition 53 | # rubocop:enable Lint/DuplicateMethods 54 | 55 | timed_task_started 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/middleware/query_cache.rb: -------------------------------------------------------------------------------- 1 | module Protobuf 2 | module ActiveRecord 3 | module Middleware 4 | class QueryCache 5 | CURRENT_CONNECTION = "_protobuf_active_record_current_connection".freeze 6 | 7 | def initialize(app) 8 | @app = app 9 | end 10 | 11 | def call(env) 12 | connection = ::Thread.current[CURRENT_CONNECTION] = ::ActiveRecord::Base.connection 13 | enabled = connection.query_cache_enabled 14 | connection.enable_query_cache! 15 | 16 | @app.call(env) 17 | ensure 18 | restore_query_cache_settings(enabled) 19 | end 20 | 21 | private 22 | 23 | def restore_query_cache_settings(enabled) 24 | ::Thread.current[CURRENT_CONNECTION].clear_query_cache 25 | ::Thread.current[CURRENT_CONNECTION].disable_query_cache! unless enabled 26 | ::Thread.current[CURRENT_CONNECTION] = nil 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/model.rb: -------------------------------------------------------------------------------- 1 | require "protobuf/active_record/attribute_methods" 2 | require "protobuf/active_record/columns" 3 | require "protobuf/active_record/errors" 4 | require "protobuf/active_record/nested_attributes" 5 | require "protobuf/active_record/persistence" 6 | require "protobuf/active_record/scope" 7 | require "protobuf/active_record/serialization" 8 | require "protobuf/active_record/transformation" 9 | require "protobuf/active_record/validations" 10 | 11 | module Protobuf 12 | module ActiveRecord 13 | module Model 14 | extend ::ActiveSupport::Concern 15 | 16 | included do 17 | include Protobuf::ActiveRecord::AttributeMethods 18 | include Protobuf::ActiveRecord::Columns 19 | include Protobuf::ActiveRecord::NestedAttributes 20 | include Protobuf::ActiveRecord::Persistence 21 | include Protobuf::ActiveRecord::Serialization 22 | include Protobuf::ActiveRecord::Scope 23 | include Protobuf::ActiveRecord::Transformation 24 | include Protobuf::ActiveRecord::Validations 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/nested_attributes.rb: -------------------------------------------------------------------------------- 1 | module Protobuf 2 | module ActiveRecord 3 | module NestedAttributes 4 | extend ::ActiveSupport::Concern 5 | 6 | included do 7 | include ::Heredity::InheritableClassInstanceVariables 8 | 9 | class << self 10 | attr_accessor :_protobuf_nested_attributes 11 | end 12 | 13 | @_protobuf_nested_attributes = [] 14 | 15 | inheritable_attributes :_protobuf_nested_attributes 16 | end 17 | 18 | module ClassMethods 19 | # :nodoc: 20 | def accepts_nested_attributes_for(*attr_names) 21 | attribute_names = attr_names.dup 22 | attribute_names.extract_options! 23 | attribute_names.map!(&:to_s) 24 | 25 | super 26 | 27 | self._protobuf_nested_attributes += attribute_names 28 | end 29 | end 30 | 31 | # :nodoc: 32 | def assign_nested_attributes_for_collection_association(association_name, attributes_collection) 33 | if attributes_collection.first.is_a?(::Protobuf::Message) 34 | reflection = self.class._reflect_on_association(association_name) 35 | attributes_collection = attributes_collection.map do |attributes| 36 | reflection.klass.attributes_from_proto(attributes) 37 | end 38 | end 39 | 40 | super 41 | end 42 | 43 | # :nodoc: 44 | def assign_nested_attributes_for_one_to_one_association(association_name, attributes) 45 | if attributes.is_a?(::Protobuf::Message) 46 | reflection = self.class._reflect_on_association(association_name) 47 | attributes = reflection.klass.attributes_from_proto(attributes) 48 | end 49 | 50 | super 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/persistence.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | 3 | module Protobuf 4 | module ActiveRecord 5 | module Persistence 6 | extend ::ActiveSupport::Concern 7 | 8 | module ClassMethods 9 | # :nodoc: 10 | def create(attributes = {}, &block) 11 | attributes = attributes_from_proto(attributes) if attributes.is_a?(::Protobuf::Message) 12 | 13 | super 14 | end 15 | 16 | # :nodoc: 17 | def create!(attributes = {}, &block) 18 | attributes = attributes_from_proto(attributes) if attributes.is_a?(::Protobuf::Message) 19 | 20 | super 21 | end 22 | end 23 | 24 | # Override Active Record's initialize method so it can accept a protobuf 25 | # message as it's attributes. 26 | # :noapi: 27 | def initialize(*args, &block) 28 | args[0] = attributes_from_proto(args.first) if args.first.is_a?(::Protobuf::Message) 29 | 30 | super 31 | end 32 | 33 | # :nodoc: 34 | def assign_attributes(attributes) 35 | attributes = attributes_from_proto(attributes) if attributes.is_a?(::Protobuf::Message) 36 | 37 | super 38 | end 39 | 40 | # :nodoc: 41 | def update(attributes) 42 | attributes = attributes_from_proto(attributes) if attributes.is_a?(::Protobuf::Message) 43 | 44 | super 45 | end 46 | 47 | # :nodoc: 48 | def update!(attributes) 49 | attributes = attributes_from_proto(attributes) if attributes.is_a?(::Protobuf::Message) 50 | 51 | super 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/railtie.rb: -------------------------------------------------------------------------------- 1 | module Protobuf 2 | module ActiveRecord 3 | class Railtie < Rails::Railtie 4 | config.protobuf_active_record = Protobuf::ActiveRecord.config 5 | 6 | ActiveSupport.on_load(:active_record) do 7 | on_inherit do 8 | include Protobuf::ActiveRecord::Model if Protobuf::ActiveRecord.config.autoload 9 | end 10 | end 11 | 12 | ActiveSupport.on_load(:protobuf_rpc_service) do 13 | Protobuf::Rpc.middleware.insert_after Protobuf::Rpc::Middleware::Logger, Middleware::ConnectionManagementAsync 14 | Protobuf::Rpc.middleware.insert_after Middleware::ConnectionManagementAsync, Middleware::QueryCache 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/scope.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | 3 | module Protobuf 4 | module ActiveRecord 5 | module Scope 6 | extend ::ActiveSupport::Concern 7 | 8 | included do 9 | class << self 10 | alias_method :by_fields, :search_scope 11 | alias_method :scope_from_proto, :search_scope 12 | end 13 | end 14 | 15 | module ClassMethods 16 | # Define fields that should be searchable via `search_scope`. Accepts a 17 | # protobuf field and an already defined scope. If no scope is specified, 18 | # the scope will be the field name, prefixed with `by_` (e.g. when the 19 | # field is :guid, the scope will be :by_guid). 20 | # 21 | # Optionally, a parser can be provided that will be called, passing the 22 | # field value as an argument. This allows custom data parsers to be used 23 | # so that they don't have to be handled by scopes. Parsers can be procs, 24 | # lambdas, or symbolized method names and must accept the value of the 25 | # field as a parameter. 26 | # 27 | # Examples: 28 | # 29 | # class User < ActiveRecord::Base 30 | # scope :by_guid, lambda { |*guids| where(:guid => guids) } 31 | # scope :custom_guid_scope, lambda { |*guids| where(:guid => guids) } 32 | # 33 | # # Equivalent to `field_scope :guid, :by_guid` 34 | # field_scope :guid 35 | # 36 | # # With a custom scope 37 | # field_scope :guid, :scope => :custom_guid_scope 38 | # 39 | # # With a custom parser that converts the value to an integer 40 | # field_scope :guid, :scope => :custom_guid_scope, :parser => lambda { |value| value.to_i } 41 | # end 42 | # 43 | def field_scope(field, options = {}) 44 | scope_name = if options.include?(:scope) 45 | options[:scope] 46 | else 47 | # When no scope is defined, assume the scope is the field, prefixed with `by_` 48 | :"by_#{field}" 49 | end 50 | searchable_fields[field] = scope_name 51 | 52 | searchable_field_parsers[field] = options[:parser] if options[:parser] 53 | end 54 | 55 | # Get an ARel relation to build off of. If we're in Rails 4 we need to 56 | # use `all` instead of `scoped`. 57 | # :noapi: 58 | def model_scope 59 | (::ActiveRecord::VERSION::MAJOR >= 4) ? all : scoped 60 | end 61 | 62 | # :noapi: 63 | def parse_search_values(proto, field) 64 | value = proto.__send__(field) 65 | 66 | if searchable_field_parsers[field] 67 | parser = searchable_field_parsers[field] 68 | 69 | value = if parser.respond_to?(:to_sym) 70 | __send__(parser.to_sym, value) 71 | else 72 | parser.call(value) 73 | end 74 | end 75 | 76 | values = [value].flatten 77 | values.map!(&:to_i) if proto.class.get_field(field, true).enum? 78 | values 79 | end 80 | 81 | # Builds and returns a Arel relation based on the fields that are present 82 | # in the given protobuf message using the searchable fields to determine 83 | # what scopes to use. Provides several aliases for variety. 84 | # 85 | # Examples: 86 | # 87 | # # Search starting with the default scope and searchable fields 88 | # User.search_scope(request) 89 | # User.by_fields(request) 90 | # User.scope_from_proto(request) 91 | # 92 | def search_scope(proto) 93 | search_relation = model_scope 94 | 95 | searchable_fields.each do |field, scope_name| 96 | next unless proto.respond_to_and_has_and_present?(field) 97 | 98 | search_values = parse_search_values(proto, field) 99 | search_relation = search_relation.__send__(scope_name, *search_values) 100 | end 101 | 102 | search_relation 103 | end 104 | 105 | # :noapi: 106 | def searchable_fields 107 | @_searchable_fields ||= {} 108 | end 109 | 110 | # :noapi: 111 | def searchable_field_parsers 112 | @_searchable_field_parsers ||= {} 113 | end 114 | 115 | # Defines a scope that is eligible for upsert. The scope will be 116 | # used to initialize a record with first_or_initialize. An upsert scope 117 | # declariation must specify one or more fields that are required to 118 | # be present on the request and also must have a field_scope defined. 119 | # 120 | # If multiple upsert scopes are specified, they will be searched in 121 | # the order they are declared for the first valid scope. 122 | # 123 | # Examples: 124 | # 125 | # class User < ActiveRecord::Base 126 | # scope :by_guid, lambda { |*guids| where(:guid => guids) } 127 | # scope :by_external_guid, lambda { |*external_guids| 128 | # where(:external_guid => exteranl_guids) 129 | # } 130 | # scope :by_client_guid, lambda { |*client_guids| 131 | # joins(:client).where( 132 | # :clients => { :guid => client_guids } 133 | # ) 134 | # } 135 | # 136 | # field_scope :guid 137 | # field_scope :client_guid 138 | # field_scope :external_guid 139 | # 140 | # upsert_scope :external_guid, :client_guid 141 | # upsert_scope :guid 142 | # 143 | # end 144 | # 145 | def upsert_key(*fields) 146 | fields = fields.flatten 147 | 148 | fields.each do |field| 149 | fail UpsertScopeError unless searchable_fields[field].present? 150 | end 151 | 152 | upsert_keys << fields 153 | end 154 | 155 | def upsert_keys 156 | @_upsert_keys ||= [] 157 | end 158 | 159 | def for_upsert(proto) 160 | valid_upsert = upsert_keys.find do |upsert_key| 161 | upsert_key.all? do |field| 162 | proto.respond_to_and_has_and_present?(field) 163 | end 164 | end 165 | 166 | fail UpsertNotFoundError unless valid_upsert.present? 167 | 168 | upsert_scope = model_scope 169 | valid_upsert.each do |field| 170 | value = proto.__send__(field) 171 | upsert_scope = upsert_scope.__send__(searchable_fields[field], value) 172 | end 173 | 174 | upsert_scope.first_or_initialize 175 | end 176 | 177 | def upsert(proto) 178 | record = for_upsert(proto) 179 | record.assign_attributes(proto) 180 | record.save 181 | record 182 | end 183 | 184 | def upsert!(proto) 185 | record = for_upsert(proto) 186 | record.assign_attributes(proto) 187 | record.save! 188 | record 189 | end 190 | end 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/serialization.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | 3 | module Protobuf 4 | module ActiveRecord 5 | module Serialization 6 | extend ::ActiveSupport::Concern 7 | 8 | included do 9 | class << self 10 | attr_writer :_protobuf_field_symbol_transformers, 11 | :_protobuf_field_transformers, 12 | :_protobuf_field_options, 13 | :protobuf_message 14 | end 15 | end 16 | 17 | module ClassMethods 18 | def _protobuf_field_objects 19 | @_protobuf_field_objects ||= {} 20 | end 21 | 22 | def _protobuf_field_options 23 | @_protobuf_field_options ||= {} 24 | end 25 | 26 | def _protobuf_field_symbol_transformers 27 | @_protobuf_field_symbol_transformers ||= {} 28 | end 29 | 30 | def _protobuf_field_transformers 31 | @_protobuf_field_transformers ||= {} 32 | end 33 | 34 | def _protobuf_message_deprecated_fields 35 | @_protobuf_message_deprecated_fields ||= protobuf_message.all_fields.map do |field| 36 | next if field.nil? 37 | next unless field.deprecated? 38 | 39 | field.name.to_sym 40 | end 41 | end 42 | 43 | def _protobuf_message_non_deprecated_fields 44 | @_protobuf_message_non_deprecated_fields ||= protobuf_message.all_fields.map do |field| 45 | next if field.nil? 46 | next if field.deprecated? 47 | 48 | field.name.to_sym 49 | end 50 | end 51 | 52 | # Define a field transformation from a record. Accepts a Symbol, 53 | # callable, or block that is called with the record being serialized. 54 | # 55 | # When given a callable or block, it is directly used to convert the field. 56 | # 57 | # When a symbol is given, it extracts the method with the same name. 58 | # 59 | # The callable or method must accept a single parameter, which is the 60 | # proto message. 61 | # 62 | # Examples: 63 | # field_from_record :public_key, :convert_public_key_to_proto 64 | # field_from_record :status, lambda { |record| # Do some stuff... } 65 | # field_from_record :status do |record| 66 | # # Do some blocky stuff... 67 | # end 68 | # 69 | def field_from_record(field, transformer = nil, &block) 70 | if transformer.is_a?(Symbol) 71 | _protobuf_field_symbol_transformers[field] = transformer 72 | return 73 | end 74 | 75 | transformer ||= block 76 | callable = transformer 77 | unless callable.respond_to?(:call) 78 | raise FieldTransformerError 79 | end 80 | 81 | _protobuf_field_transformers[field.to_sym] = callable 82 | end 83 | 84 | # Define the protobuf fields that will be automatically serialized (by default, 85 | # all fields will be serialized). Accepts any number of field names and is 86 | # equivalent to passing the :only option to `protobuf_message`. 87 | # 88 | # If :except is specified, all fields except the specified fields will be serialized. 89 | # 90 | # By default, deprecated fields will be serialized. To exclude deprecated 91 | # fields, pass :deprecated => false in the options hash. 92 | # 93 | # Examples: 94 | # protobuf_fields :guid, :name 95 | # protobuf_fields :except => :email_domain 96 | # protobuf_fields :except => :email_domain, :deprecated => false 97 | # 98 | def protobuf_fields(*fields) 99 | options = fields.extract_options! 100 | options[:only] = fields if fields.present? 101 | 102 | self._protobuf_field_options = options 103 | end 104 | 105 | # Define the protobuf message class that should be used to serialize the 106 | # object to protobuf. Accepts a string or symbol and an options hash. 107 | # 108 | # When protobuf_message is declared, Protoable automatically extracts the 109 | # fields from the message and automatically adds a to_proto method that 110 | # serializes the object to protobuf. 111 | # 112 | # The fields that will be automatically serialized can be configured by 113 | # passing :only or :except in the options hash. If :only is specified, only 114 | # the specified fields will be serialized. If :except is specified, all 115 | # field except the specified fields will be serialized. 116 | # 117 | # By default, deprecated fields will be serialized. To exclude deprecated 118 | # fields, pass :deprecated => false in the options hash. 119 | # 120 | # Examples: 121 | # protobuf_message :user_message 122 | # protobuf_message "UserMessage" 123 | # protobuf_message "Namespaced::UserMessage" 124 | # protobuf_message :user_message, :only => [ :guid, :name ] 125 | # protobuf_message :user_message, :except => :email_domain 126 | # protobuf_message :user_message, :except => :email_domain, :deprecated => false 127 | # 128 | def protobuf_message(message = nil, options = {}) 129 | unless message.nil? 130 | @protobuf_message = message.to_s.classify.constantize 131 | self._protobuf_field_options = options 132 | end 133 | 134 | @protobuf_message 135 | end 136 | 137 | class CollectionAssociationCaller 138 | def initialize(method_name) 139 | @method_name = method_name 140 | end 141 | 142 | def call(selph) 143 | selph.__send__(@method_name).to_a 144 | rescue NameError # Has happened when field is not on model or ignored from db 145 | nil 146 | end 147 | end 148 | 149 | def _protobuf_collection_association_object(field) 150 | CollectionAssociationCaller.new(field) 151 | end 152 | 153 | class DateCaller 154 | def initialize(field) 155 | @field = field 156 | end 157 | 158 | def call(selph) 159 | value = selph.__send__(@field) 160 | 161 | if value 162 | value.to_time(:utc).to_i 163 | end 164 | rescue NameError # Has happened when field is not on model or ignored from db 165 | nil 166 | end 167 | end 168 | 169 | class DateTimeCaller 170 | def initialize(field) 171 | @field = field 172 | end 173 | 174 | def call(selph) 175 | value = selph.__send__(@field) 176 | 177 | value&.to_i 178 | rescue NameError # Has happened when field is not on model or ignored from db 179 | nil 180 | end 181 | end 182 | 183 | class NoConversionCaller 184 | def initialize(field) 185 | @field = field 186 | end 187 | 188 | def call(selph) 189 | selph.__send__(@field) 190 | rescue NameError # Has happened when field is not on model or ignored from db 191 | nil 192 | end 193 | end 194 | 195 | def _protobuf_convert_to_fields_object(field) 196 | is_datetime_time_or_timestamp_column = _protobuf_date_datetime_time_or_timestamp_column?(field) 197 | is_date_column = _protobuf_date_column?(field) 198 | 199 | if is_datetime_time_or_timestamp_column 200 | if is_date_column 201 | DateCaller.new(field) 202 | else 203 | DateTimeCaller.new(field) 204 | end 205 | else 206 | NoConversionCaller.new(field) 207 | end 208 | end 209 | 210 | def _protobuf_field_transformer_object(field) 211 | _protobuf_field_transformers[field] 212 | end 213 | 214 | class NilMethodCaller 215 | def initialize 216 | end 217 | 218 | def call(_selph) 219 | nil 220 | end 221 | end 222 | 223 | def _protobuf_nil_object(_field) 224 | NilMethodCaller.new 225 | end 226 | 227 | class FieldSymbolTransformerCaller 228 | def initialize(instance_class, method_name) 229 | @instance_class = instance_class 230 | @method_name = method_name 231 | end 232 | 233 | def call(selph) 234 | @instance_class.__send__(@method_name, selph) 235 | end 236 | end 237 | 238 | def _protobuf_symbol_transformer_object(field) 239 | FieldSymbolTransformerCaller.new(self, _protobuf_field_symbol_transformers[field]) 240 | end 241 | end 242 | 243 | # :nodoc: 244 | def _filter_field_attributes(options = {}) 245 | options = _normalize_options(options) 246 | 247 | fields = _filtered_fields(options) 248 | fields &= [options[:only]].flatten if options[:only].present? 249 | fields -= [options[:except]].flatten if options[:except].present? 250 | 251 | fields 252 | end 253 | 254 | # :nodoc: 255 | def _filtered_fields(options = {}) 256 | include_deprecated = options.fetch(:deprecated, true) 257 | 258 | fields = [] 259 | fields.concat(self.class._protobuf_message_non_deprecated_fields) 260 | fields.concat(self.class._protobuf_message_deprecated_fields) if include_deprecated 261 | fields.concat([options[:include]].flatten) if options[:include].present? 262 | fields.compact! 263 | fields.uniq! 264 | 265 | fields 266 | end 267 | 268 | # :nodoc: 269 | def _is_collection_association?(field) 270 | reflection = self.class.reflect_on_association(field) 271 | return false unless reflection 272 | 273 | reflection.macro == :has_many 274 | end 275 | 276 | # :nodoc: 277 | def _normalize_options(options) 278 | options ||= {} 279 | options[:only] ||= [] if options.fetch(:except, false) 280 | options[:except] ||= [] if options.fetch(:only, false) 281 | 282 | self.class._protobuf_field_options.merge(options) 283 | end 284 | 285 | # Extracts attributes that correspond to fields on the specified protobuf 286 | # message, performing any necessary column conversions on them. Accepts a 287 | # hash of options for specifying which fields should be serialized. 288 | # 289 | # Examples: 290 | # fields_from_record(:only => [ :guid, :name ]) 291 | # fields_from_record(:except => :email_domain) 292 | # fields_from_record(:include => :email_domain) 293 | # fields_from_record(:except => :email_domain, :deprecated => false) 294 | # 295 | def fields_from_record(options = {}) 296 | hash = {} 297 | field_attributes = _filter_field_attributes(options) 298 | 299 | # Already flattened / compacted / uniqued ... unless we must include 300 | if options[:include].present? 301 | field_attributes.concat([options[:include]].flatten) 302 | field_attributes.compact! 303 | field_attributes.uniq! 304 | end 305 | 306 | attribute_number = 0 307 | limit = field_attributes.size 308 | 309 | # One of the very few places the diff between each/while can make a difference 310 | # in terms of optimization (`while` is slightly faster as no block carried through) 311 | while attribute_number < limit 312 | field = field_attributes[attribute_number] 313 | field_object = _protobuf_field_objects(field) 314 | hash[field] = field_object.call(self) 315 | attribute_number += 1 316 | end 317 | 318 | hash 319 | end 320 | 321 | # :nodoc: 322 | def _protobuf_field_objects(field) 323 | self.class._protobuf_field_objects[field] ||= if _protobuf_field_symbol_transformers.key?(field) 324 | 325 | self.class._protobuf_symbol_transformer_object(field) 326 | elsif _protobuf_field_transformers.key?(field) 327 | 328 | self.class._protobuf_field_transformer_object(field) 329 | elsif respond_to?(field) 330 | 331 | if _is_collection_association?(field) 332 | self.class._protobuf_collection_association_object(field) 333 | else 334 | self.class._protobuf_convert_to_fields_object(field) 335 | end 336 | else 337 | self.class._protobuf_nil_object(field) 338 | end 339 | end 340 | 341 | # :nodoc: 342 | def _protobuf_field_symbol_transformers 343 | self.class._protobuf_field_symbol_transformers 344 | end 345 | 346 | # :nodoc: 347 | def _protobuf_field_transformers 348 | self.class._protobuf_field_transformers 349 | end 350 | 351 | # :nodoc: 352 | def _protobuf_message 353 | self.class.protobuf_message 354 | end 355 | 356 | # :nodoc: 357 | def to_proto(options = {}) 358 | raise MessageNotDefined, self.class if _protobuf_message.nil? 359 | 360 | fields = fields_from_record(options) 361 | _protobuf_message.new(fields) 362 | end 363 | end 364 | end 365 | end 366 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/transformation.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | require "heredity/inheritable_class_instance_variables" 3 | require "protobuf/active_record/transformer" 4 | 5 | module Protobuf 6 | module ActiveRecord 7 | module Transformation 8 | extend ::ActiveSupport::Concern 9 | 10 | included do 11 | include ::Heredity::InheritableClassInstanceVariables 12 | 13 | class << self 14 | attr_accessor :_protobuf_attribute_transformers 15 | end 16 | 17 | @_protobuf_attribute_transformers = {} 18 | 19 | inheritable_attributes :_protobuf_attribute_transformers 20 | end 21 | 22 | module ClassMethods 23 | # Filters accessible attributes that exist in the given protobuf message's 24 | # fields or have attribute transformers defined for them. 25 | # 26 | # Returns a hash of attribute fields with their respective values. 27 | # 28 | # :nodoc: 29 | def _filter_attribute_fields(proto) 30 | fields = proto.to_hash 31 | fields.select! do |key, _value| 32 | field = proto.class.get_field(key, true) 33 | proto.field?(key) && !field.repeated? 34 | end 35 | 36 | filtered_attributes = _filtered_attributes + _protobuf_attribute_transformers.keys 37 | 38 | attribute_fields = filtered_attributes.each_with_object({}) do |column_name, hash| 39 | symbolized_column = column_name.to_sym 40 | 41 | if fields.key?(symbolized_column) || _protobuf_attribute_transformers.key?(symbolized_column) 42 | hash[symbolized_column] = fields[symbolized_column] 43 | end 44 | end 45 | 46 | _protobuf_nested_attributes.each do |attribute_name| 47 | nested_attribute_name = :"#{attribute_name}_attributes" 48 | value = if proto.field?(nested_attribute_name) 49 | proto.__send__(nested_attribute_name) 50 | elsif proto.field?(attribute_name) 51 | proto.__send__(attribute_name) 52 | end 53 | 54 | next unless value 55 | attribute_fields[nested_attribute_name] = value 56 | end 57 | 58 | attribute_fields 59 | end 60 | 61 | # Overidden by mass assignment security when protected attributes is loaded. 62 | # 63 | # :nodoc: 64 | def _filtered_attributes 65 | attribute_names 66 | end 67 | 68 | # :nodoc: 69 | def _protobuf_convert_fields_to_attributes(key, value) 70 | return nil if value.nil? 71 | return value unless _protobuf_date_datetime_time_or_timestamp_column?(key) 72 | 73 | if _protobuf_datetime_column?(key) 74 | 75 | convert_int64_to_datetime(value) 76 | elsif _protobuf_timestamp_column?(key) 77 | 78 | convert_int64_to_time(value) 79 | elsif _protobuf_time_column?(key) 80 | 81 | convert_int64_to_time(value) 82 | elsif _protobuf_date_column?(key) 83 | 84 | convert_int64_to_date(value) 85 | end 86 | end 87 | 88 | # Define an attribute transformation from protobuf. Accepts a Symbol, 89 | # callable, or block. 90 | # 91 | # When given a callable or block, it is directly used to convert the field. 92 | # 93 | # When a symbol is given, it extracts the method with the same name. 94 | # 95 | # The callable or method must accept a single parameter, which is the 96 | # proto message. 97 | # 98 | # Examples: 99 | # attribute_from_proto :public_key, :extract_public_key_from_proto 100 | # attribute_from_proto :status, lambda { |proto| # Do some stuff... } 101 | # attribute_from_proto :status do |proto| 102 | # # Do some blocky stuff... 103 | # end 104 | # 105 | # attribute_from_proto :status, lambda { |proto| nil }, :nullify_on => :status 106 | # attribute_from_proto :status, :nullify_on => :status do |proto| 107 | # nil 108 | # end 109 | # 110 | def attribute_from_proto(attribute, *args, &block) 111 | options = args.extract_options! 112 | symbol_or_block = args.first || block 113 | 114 | if symbol_or_block.is_a?(Symbol) 115 | callable = lambda { |value| __send__(symbol_or_block, value) } 116 | else 117 | raise AttributeTransformerError unless symbol_or_block.respond_to?(:call) 118 | callable = symbol_or_block 119 | end 120 | 121 | if options[:nullify_on] 122 | field = protobuf_message.get_field(:nullify) 123 | unless field&.is_a?(::Protobuf::Field::StringField) && field&.repeated? 124 | ::Protobuf::Logging.logger.warn "Message: #{protobuf_message} is not compatible with :nullify_on option" 125 | end 126 | end 127 | 128 | transformer = ::Protobuf::ActiveRecord::Transformer.new(callable, options) 129 | _protobuf_attribute_transformers[attribute.to_sym] = transformer 130 | end 131 | 132 | # Creates a hash of attributes from a given protobuf message. 133 | # 134 | # It converts and transforms field values using the field converters and 135 | # attribute transformers, ignoring repeated and nil fields. 136 | # 137 | def attributes_from_proto(proto) 138 | attribute_fields = _filter_attribute_fields(proto) 139 | 140 | attributes = attribute_fields.each_with_object({}) do |(key, value), hash| 141 | if _protobuf_attribute_transformers.key?(key) 142 | transformer = _protobuf_attribute_transformers[key] 143 | attribute = transformer.call(proto) 144 | hash[key] = attribute unless attribute.nil? 145 | hash[key] = nil if transformer.nullify?(proto) 146 | else 147 | hash[key] = _protobuf_convert_fields_to_attributes(key, value) 148 | end 149 | end 150 | 151 | return attributes unless proto.field?(:nullify) && proto.nullify.is_a?(Array) 152 | 153 | proto.nullify.each do |attribute_name| 154 | attributes[attribute_name.to_sym] = nil if attribute_names.include?(attribute_name.to_s) 155 | end 156 | 157 | attributes 158 | end 159 | 160 | # :nodoc: 161 | def convert_int64_to_time(int64) 162 | Time.at(int64.to_i) 163 | end 164 | 165 | # :nodoc: 166 | def convert_int64_to_date(int64) 167 | convert_int64_to_time(int64).utc.to_date 168 | end 169 | 170 | # :nodoc: 171 | def convert_int64_to_datetime(int64) 172 | convert_int64_to_time(int64).to_datetime 173 | end 174 | end 175 | 176 | # Calls up to the class version of the method. 177 | # 178 | def attributes_from_proto(proto) 179 | self.class.attributes_from_proto(proto) 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/transformer.rb: -------------------------------------------------------------------------------- 1 | module Protobuf 2 | module ActiveRecord 3 | class Transformer 4 | attr_accessor :callable, :options 5 | 6 | def initialize(callable, options = {}) 7 | @callable = callable 8 | @options = options 9 | end 10 | 11 | delegate :call, to: :callable 12 | 13 | def nullify?(proto) 14 | return false unless options[:nullify_on] 15 | return false unless proto.field?(:nullify) && proto.nullify.is_a?(Array) 16 | return false if proto.nullify.empty? 17 | 18 | proto.nullify.include?(options[:nullify_on].to_s) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/validations.rb: -------------------------------------------------------------------------------- 1 | require "active_support/concern" 2 | 3 | module Protobuf 4 | module ActiveRecord 5 | module Validations 6 | extend ::ActiveSupport::Concern 7 | 8 | module ClassMethods 9 | # Validates whether the value of the specified attribute is available in 10 | # the given Protobuf Enum. The enumeration should be passed as a class 11 | # that defines the enumeration: 12 | # 13 | # ``` 14 | # class User < ActiveRecord::Base 15 | # include ::Protoable 16 | # 17 | # validates_enumeration_of :role_type, :with => RoleType, :allow_nil => true 18 | # end 19 | # ``` 20 | # 21 | # In this example, RoleType is a defined as a protobuf enum. 22 | # 23 | # It accepts the same options as `validates_inclusion_of` (the :in option 24 | # is automatically set and will be overwritten). 25 | # 26 | def validates_enumeration_of(*args) 27 | options = args.extract_options! 28 | enumerable = options.delete(:with) 29 | 30 | raise ArgumentError, ":with must be specified" if enumerable.nil? 31 | 32 | if enumerable < ::Protobuf::Enum 33 | options[:in] = enumerable.all_tags 34 | end 35 | 36 | args << options 37 | 38 | validates_inclusion_of(*args) 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/protobuf/active_record/version.rb: -------------------------------------------------------------------------------- 1 | module Protobuf 2 | module ActiveRecord 3 | VERSION = "7.1.0" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /protobuf-activerecord.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("../lib", __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "protobuf/active_record/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "protobuf-activerecord" 9 | spec.version = Protobuf::ActiveRecord::VERSION 10 | spec.authors = ["Adam Hutchison"] 11 | spec.email = ["liveh2o@gmail.com"] 12 | 13 | spec.summary = "Google Protocol Buffers integration for Active Record" 14 | spec.description = "Provides the ability to create Active Record objects from Protocol Buffer messages and vice versa." 15 | spec.homepage = "http://github.com/liveh2o/protobuf-activerecord" 16 | spec.license = "MIT" 17 | spec.required_ruby_version = ">= 2.7.0" 18 | 19 | spec.metadata["homepage_uri"] = spec.homepage 20 | spec.metadata["source_code_uri"] = spec.homepage 21 | spec.metadata["changelog_uri"] = spec.homepage + "/blob/main/CHANGELOG.md" 22 | 23 | # Specify which files should be added to the gem when it is released. 24 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 25 | gemspec = File.basename(__FILE__) 26 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 27 | ls.readlines("\x0", chomp: true).reject do |f| 28 | (f == gemspec) || 29 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) 30 | end 31 | end 32 | spec.bindir = "exe" 33 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 34 | spec.require_paths = ["lib"] 35 | 36 | ## 37 | # Dependencies 38 | # 39 | spec.add_dependency "activerecord", "~> 7.1.0" 40 | spec.add_dependency "activesupport", "~> 7.1.0" 41 | spec.add_dependency "concurrent-ruby" 42 | spec.add_dependency "heredity", ">= 0.1.1" 43 | spec.add_dependency "protobuf", ">= 3.0" 44 | end 45 | -------------------------------------------------------------------------------- /spec/protobuf/active_record/columns_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Protobuf::ActiveRecord::Columns do 4 | describe "._protobuf_map_columns" do 5 | context "when the class has a table" do 6 | let(:expected_column_names) { 7 | User.columns.each_with_object({}) do |column, hash| 8 | hash[column.name.to_sym] = column 9 | end 10 | } 11 | 12 | let(:expected_column_types) { 13 | User.columns.each_with_object({}) do |column, hash| 14 | hash[column.type.to_sym] ||= ::Set.new 15 | hash[column.type.to_sym] << column.name.to_sym 16 | end 17 | } 18 | 19 | it "maps columns by name" do 20 | expect(User._protobuf_columns).to eq expected_column_names 21 | end 22 | 23 | it "maps column names by column type" do 24 | expected_column_types.each do |expected_column_type, value| 25 | expect(User._protobuf_column_types).to include expected_column_type => value 26 | end 27 | end 28 | end 29 | end 30 | 31 | context "column type predicates" do 32 | before { allow(User).to receive(:_protobuf_column_types).and_return({}) } 33 | 34 | describe "._protobuf_date_column?" do 35 | before { User._protobuf_column_types[:date] = [:foo_date] } 36 | 37 | context "when the column type is :date" do 38 | it "is true" do 39 | expect(User._protobuf_date_column?(:foo_date)).to be true 40 | end 41 | end 42 | 43 | context "when the column type is not :date" do 44 | it "is false" do 45 | expect(User._protobuf_date_column?(:bar_date)).to be false 46 | end 47 | end 48 | end 49 | 50 | describe "._protobuf_datetime_column?" do 51 | before { User._protobuf_column_types[:datetime] = [:foo_datetime] } 52 | 53 | context "when the column type is :datetime" do 54 | it "is true" do 55 | expect(User._protobuf_datetime_column?(:foo_datetime)).to be true 56 | end 57 | end 58 | 59 | context "when the column type is not :datetime" do 60 | it "is false" do 61 | expect(User._protobuf_datetime_column?(:bar_datetime)).to be false 62 | end 63 | end 64 | end 65 | 66 | describe "._protobuf_time_column?" do 67 | before { User._protobuf_column_types[:time] = [:foo_time] } 68 | 69 | context "when the column type is :time" do 70 | it "is true" do 71 | expect(User._protobuf_time_column?(:foo_time)).to be true 72 | end 73 | end 74 | 75 | context "when the column type is not :time" do 76 | it "is false" do 77 | expect(User._protobuf_time_column?(:bar_time)).to be false 78 | end 79 | end 80 | end 81 | 82 | describe "._protobuf_timestamp_column?" do 83 | before { User._protobuf_column_types[:timestamp] = [:foo_timestamp] } 84 | 85 | context "when the column type is :timestamp" do 86 | it "is true" do 87 | expect(User._protobuf_timestamp_column?(:foo_timestamp)).to be true 88 | end 89 | end 90 | 91 | context "when the column type is not :timestamp" do 92 | it "is false" do 93 | expect(User._protobuf_timestamp_column?(:bar_timestamp)).to be false 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/protobuf/active_record/nested_attributes_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Protobuf::ActiveRecord::NestedAttributes do 4 | let(:user_message) { 5 | UserMessage.new(name: "foo bar", email: "foo@test.co", photos: [{url: "https://test.co/test.png"}]) 6 | } 7 | 8 | describe "._filter_attribute_fields", aggregate_failures: true do 9 | it "includes nested attributes" do 10 | attribute_fields = User._filter_attribute_fields(user_message) 11 | expect(attribute_fields[:photos_attributes]).to eq(user_message.photos) 12 | end 13 | 14 | context "when" do 15 | end 16 | end 17 | 18 | describe ".new" do 19 | context "when a model accepts nested attributes" do 20 | it "transforms nested attributes", aggregate_failures: true do 21 | user_message.photos.each do |photo_message| 22 | expect(Photo).to receive(:attributes_from_proto).with(photo_message).and_call_original 23 | end 24 | User.new(user_message) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/protobuf/active_record/persistence_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Protobuf::ActiveRecord::Persistence do 4 | let(:user) { User.new(user_attributes) } 5 | let(:user_attributes) { {first_name: "foo", last_name: "bar", email: "foo@test.co"} } 6 | let(:proto_hash) { {name: "foo bar", email: "foo@test.co"} } 7 | let(:proto) { UserMessage.new(proto_hash) } 8 | 9 | describe ".create" do 10 | it "accepts a protobuf message" do 11 | expect_any_instance_of(User).to receive(:save) 12 | User.create(proto) 13 | end 14 | 15 | it "accepts a hash" do 16 | expect_any_instance_of(User).to receive(:save) 17 | User.create(user_attributes) 18 | end 19 | end 20 | 21 | describe ".create!" do 22 | it "accepts a protobuf message" do 23 | expect_any_instance_of(User).to receive(:save!) 24 | User.create!(proto) 25 | end 26 | 27 | it "accepts a hash" do 28 | expect_any_instance_of(User).to receive(:save!) 29 | User.create!(user_attributes) 30 | end 31 | end 32 | 33 | describe "#assign_attributes" do 34 | let(:user) { ::User.new } 35 | 36 | it "accepts a protobuf message" do 37 | user.assign_attributes(proto) 38 | expect(user.changed?).to be true 39 | end 40 | 41 | it "accepts a hash" do 42 | user.assign_attributes(user_attributes) 43 | expect(user.changed?).to be true 44 | end 45 | end 46 | 47 | describe "#update" do 48 | it "accepts a protobuf message" do 49 | expect_any_instance_of(User).to receive(:save) 50 | user.update(proto) 51 | end 52 | 53 | it "accepts a hash" do 54 | expect_any_instance_of(User).to receive(:save) 55 | user.update(user_attributes) 56 | end 57 | end 58 | 59 | describe "#update!" do 60 | it "accepts a protobuf message" do 61 | expect_any_instance_of(User).to receive(:save!) 62 | user.update!(proto) 63 | end 64 | 65 | it "accepts a hash" do 66 | expect_any_instance_of(User).to receive(:save!) 67 | user.update!(user_attributes) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/protobuf/active_record/scope_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | class TheEnum < ::Protobuf::Enum 4 | define :VALUE, 1 5 | end 6 | 7 | class TheMessage < ::Protobuf::Message 8 | optional TheEnum, :the_enum_value, 1 9 | end 10 | 11 | RSpec.describe Protobuf::ActiveRecord::Scope do 12 | before do 13 | @field_parsers = User.instance_variable_get(:@_searchable_field_parsers) 14 | @fields = User.instance_variable_get(:@_searchable_fields) 15 | end 16 | 17 | after do 18 | User.instance_variable_set(:@_searchable_field_parsers, @field_parsers) 19 | User.instance_variable_set(:@_searchable_fields, @fields) 20 | User.instance_variable_set(:@_upsert_keys, []) 21 | end 22 | 23 | describe ".search_scope" do 24 | let(:request) { UserSearchMessage.new(guid: ["foo"], email: ["foo@test.co"]) } 25 | 26 | before { 27 | allow(User).to receive(:searchable_field_parsers).and_return(email: proc { |val| val }) 28 | } 29 | 30 | it "builds scopes for searchable fields" do 31 | allow(User).to receive(:searchable_fields).and_return(email: :by_email) 32 | expect(User.search_scope(request)).to eq User.by_email("foo@test.co") 33 | end 34 | 35 | it "is chainable" do 36 | expect(User.limit(1).search_scope(request).order(:email)).to eq User.limit(1).order(:email) 37 | end 38 | 39 | context "when a searchable field does not have a value" do 40 | let(:request) { UserSearchMessage.new(email: ["foo@test.co"]) } 41 | 42 | it "doesn't build a scope from that field" do 43 | allow(User).to receive(:searchable_fields).and_return(email: :by_email) 44 | expect(User.search_scope(request)).to eq User.by_email("foo@test.co") 45 | end 46 | end 47 | 48 | context "when a searchable field uses a non-existant scope" do 49 | let(:request) { UserSearchMessage.new(email: ["foo@test.co"]) } 50 | 51 | it "raises an exception" do 52 | allow(User).to receive(:searchable_fields).and_return(email: :by_hullabaloo) 53 | expect { User.search_scope(request) }.to raise_exception(/undefined method .*by_hullabaloo/i) 54 | end 55 | end 56 | end 57 | 58 | describe ".field_scope" do 59 | context "when :scope is not defined" do 60 | it "defines the given field as searchable using the `by_[:field]` as the scope" do 61 | User.field_scope :guid 62 | expect(User.searchable_fields[:guid]).to eq :by_guid 63 | end 64 | end 65 | 66 | context "when :scope is defined" do 67 | it "defines the given field as searchable using the given :scope" do 68 | User.field_scope :guid, scope: :custom_scope 69 | expect(User.searchable_fields[:guid]).to eq :custom_scope 70 | end 71 | end 72 | 73 | context "when :parser is not defined" do 74 | it "doesn't define the given field as parseable" do 75 | User.field_scope :guid 76 | expect(User.searchable_field_parsers[:guid]).to eq nil 77 | end 78 | end 79 | 80 | context "when :parser is defined" do 81 | it "defines the given field as parseable using the given :parser" do 82 | User.field_scope :guid, parser: :parser 83 | expect(User.searchable_field_parsers[:guid]).to eq :parser 84 | end 85 | end 86 | end 87 | 88 | describe ".parse_search_values" do 89 | it "converts single values to collections" do 90 | proto = UserMessage.new(email: "the.email@test.in") 91 | 92 | User.field_scope :email 93 | expect(User.parse_search_values(proto, :email)).to eq ["the.email@test.in"] 94 | end 95 | 96 | context "when a field parser is defined" do 97 | before { User.field_scope :guid, parser: parser } 98 | 99 | let(:proto) { UserSearchMessage.new(guid: ["foo"]) } 100 | 101 | context "and the parser does not respond to :to_sym" do 102 | let(:parser) { double("parser") } 103 | 104 | it "passes the value to the parser" do 105 | expect(parser).to receive(:call).with(["foo"]) 106 | User.parse_search_values(proto, :guid) 107 | end 108 | end 109 | end 110 | 111 | context "when the field is an enum" do 112 | it "maps values to integers" do 113 | proto = TheMessage.new(the_enum_value: TheEnum::VALUE) 114 | expect(User.parse_search_values(proto, :the_enum_value)[0]).to be 1 115 | end 116 | end 117 | end 118 | 119 | describe ".upsert_key" do 120 | it "adds the fields to the upsert_keys" do 121 | ::User.field_scope(:guid) 122 | ::User.upsert_key(:guid) 123 | expect(::User.upsert_keys).to eq([[:guid]]) 124 | end 125 | 126 | context "no field_scope defined" do 127 | it "raises an error" do 128 | expect { ::User.upsert_key(:foobar) }.to raise_error(::Protobuf::ActiveRecord::UpsertScopeError) 129 | end 130 | end 131 | end 132 | 133 | describe ".for_upsert" do 134 | let(:guid) { "USR-1" } 135 | let(:proto) { ::UserMessage.new(guid: guid) } 136 | 137 | before do 138 | ::User.delete_all 139 | ::User.field_scope(:guid) 140 | ::User.upsert_key(:guid) 141 | end 142 | 143 | context "no matching upsert keys" do 144 | let(:proto) { ::UserMessage.new } 145 | 146 | it "raises an error" do 147 | expect { ::User.for_upsert(proto) }.to raise_error(::Protobuf::ActiveRecord::UpsertNotFoundError) 148 | end 149 | end 150 | 151 | context "no existing records" do 152 | it "returns a new record" do 153 | record = ::User.for_upsert(proto) 154 | expect(record.new_record?).to be true 155 | end 156 | end 157 | 158 | context "existing record" do 159 | before { ::User.create(guid: guid) } 160 | after { ::User.delete_all } 161 | 162 | it "returns the existing record" do 163 | record = ::User.for_upsert(proto) 164 | expect(record.new_record?).to be false 165 | end 166 | end 167 | end 168 | 169 | describe ".upsert" do 170 | let(:guid) { "USR-1" } 171 | let(:proto) { ::UserMessage.new(guid: guid, email: "bar") } 172 | 173 | before do 174 | ::User.delete_all 175 | ::User.field_scope(:guid) 176 | ::User.upsert_key(:guid) 177 | end 178 | 179 | context "no existing records" do 180 | it "creates a new record" do 181 | ::User.upsert(proto) 182 | expect(::User.count).to eq(1) 183 | end 184 | end 185 | 186 | context "existing record" do 187 | before { ::User.create(guid: guid, email: "foo") } 188 | after { ::User.delete_all } 189 | 190 | it "updates the existing record" do 191 | ::User.upsert(proto) 192 | expect(::User.first.email).to eq("bar") 193 | end 194 | 195 | it "returns a user" do 196 | result = ::User.upsert(proto) 197 | expect(result).to be_a(::User) 198 | end 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /spec/protobuf/active_record/serialization_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | # Used to test calling #to_proto when no protobuf message is configured. 4 | class UnconfiguredUser 5 | include Protobuf::ActiveRecord::Model 6 | end 7 | 8 | RSpec.describe Protobuf::ActiveRecord::Serialization do 9 | let(:protobuf_message) { UserMessage } 10 | 11 | describe ".field_from_record" do 12 | context "when the given transformer is a symbol" do 13 | before { User.field_from_record :first_name, :extract_first_name } 14 | 15 | it "creates a symbol transformer from the converter" do 16 | expect(User._protobuf_field_symbol_transformers[:first_name]).to eq :extract_first_name 17 | end 18 | end 19 | 20 | context "when the given transformer is not callable" do 21 | it "raises an exception" do 22 | expect { User.field_from_record :name, nil }.to raise_exception(Protobuf::ActiveRecord::FieldTransformerError) 23 | end 24 | end 25 | 26 | context "when the given transformer is callable" do 27 | let(:callable) { lambda { |_proto| } } 28 | 29 | before { 30 | allow(User).to receive(:_protobuf_field_transformers).and_return({}) 31 | User.field_from_record :account_id, callable 32 | } 33 | 34 | it "adds the given converter to the list of protobuf field transformers" do 35 | expect(User._protobuf_field_transformers[:account_id]).to eq(callable) 36 | end 37 | end 38 | end 39 | 40 | describe ".protobuf_message" do 41 | let(:options) { {only: []} } 42 | 43 | before { User.protobuf_message(protobuf_message, options) } 44 | after { User.protobuf_message(protobuf_message, {}) } 45 | 46 | context "given a value" do 47 | it "defines #to_proto" do 48 | expect(User.allocate).to respond_to :to_proto 49 | end 50 | end 51 | 52 | context "given options" do 53 | it "merges them with protobuf field options" do 54 | expect(User._protobuf_field_options).to eq options 55 | end 56 | end 57 | 58 | it "returns the protobuf message for this object" do 59 | expect(User.protobuf_message).to eq protobuf_message 60 | end 61 | end 62 | 63 | context "when protobuf_message is defined" do 64 | let(:attributes) { {} } 65 | let(:user) { User.new(attributes) } 66 | 67 | before { User.protobuf_message(protobuf_message) } 68 | 69 | describe "#_filter_field_attributes" do 70 | context "when options has :only" do 71 | it "only returns the given field(s)" do 72 | fields = user._filter_field_attributes(only: :name) 73 | expect(fields).to eq [:name] 74 | end 75 | end 76 | 77 | context "when options has :except" do 78 | it "returns all except the given field(s)" do 79 | fields = user._filter_field_attributes(except: :name) 80 | expect(fields).to match_array( 81 | [:guid, :email, :email_domain, :password, :nullify, :photos, :created_at, :updated_at] 82 | ) 83 | end 84 | end 85 | end 86 | 87 | describe "#_filtered_fields" do 88 | it "returns protobuf fields" do 89 | expect(user._filtered_fields).to match_array( 90 | [:guid, :name, :email, :email_domain, :password, :nullify, :photos, :created_at, :updated_at] 91 | ) 92 | end 93 | 94 | context "given :deprecated => false" do 95 | it "filters all deprecated fields" do 96 | fields = user._filtered_fields(deprecated: false) 97 | expect(fields).to match_array( 98 | [:guid, :name, :email, :password, :nullify, :photos, :created_at, :updated_at] 99 | ) 100 | end 101 | 102 | context "and :include => :email_domain" do 103 | it "includes deprecated fields that have been explicitly specified" do 104 | fields = user._filtered_fields(deprecated: false, include: :email_domain) 105 | expect(fields).to match_array( 106 | [:guid, :name, :email, :email_domain, :password, :nullify, :photos, :created_at, :updated_at] 107 | ) 108 | end 109 | end 110 | end 111 | end 112 | 113 | describe "#_normalize_options" do 114 | let(:options) { {only: [:name]} } 115 | 116 | context "given empty options" do 117 | before { User.protobuf_message(protobuf_message, options) } 118 | 119 | it "returns the class's protobuf field options" do 120 | expect(User.allocate._normalize_options({})).to eq options 121 | end 122 | end 123 | 124 | context "given options" do 125 | before { User.protobuf_message(protobuf_message, {}) } 126 | 127 | it "merges them with the class's protobuf field options" do 128 | normalized_options = User.allocate._normalize_options(options) 129 | expect(normalized_options[:only]).to eq options[:only] 130 | end 131 | end 132 | 133 | context "given options with :only" do 134 | before { User.protobuf_message(protobuf_message, {}) } 135 | 136 | it "ensures that :except exists" do 137 | normalized_options = User.allocate._normalize_options(options) 138 | expect(normalized_options[:except]).to eq [] 139 | end 140 | end 141 | 142 | context "given options with :except" do 143 | let(:options) { {except: [:name]} } 144 | 145 | before { User.protobuf_message(protobuf_message, {}) } 146 | 147 | it "ensures that :only exists" do 148 | normalized_options = User.allocate._normalize_options(options) 149 | expect(normalized_options[:only]).to eq [] 150 | end 151 | end 152 | end 153 | 154 | describe "#fields_from_record" do 155 | let(:attributes) { 156 | { 157 | guid: "foo", 158 | first_name: "bar", 159 | last_name: "baz", 160 | email: "foo@test.co" 161 | } 162 | } 163 | 164 | context "when a transformer is defined for the field" do 165 | it "gets the field from the transformer" do 166 | expect(user.fields_from_record[:email_domain]).to eq("test.co") 167 | end 168 | end 169 | 170 | context "given options with :include" do 171 | it "adds the given field to the list of serialized fields" do 172 | fields = user.fields_from_record(include: :token) 173 | expect(fields).to include(:token) 174 | end 175 | end 176 | 177 | context "when a field is a collection association" do 178 | let(:user) { User.create(attributes) } 179 | 180 | it "terminates the association proxy" do 181 | fields = user.fields_from_record(include: :photos) 182 | expect(fields[:photos]).to be_an(Array) 183 | end 184 | end 185 | end 186 | 187 | describe "#to_proto" do 188 | context "when a protobuf message is configured" do 189 | let(:proto) { protobuf_message.new(proto_hash) } 190 | let(:proto_hash) { {name: "foo"} } 191 | 192 | before { allow(user).to receive(:fields_from_record).and_return(proto_hash) } 193 | 194 | it "intializes a new protobuf message with attributes from #to_proto_hash" do 195 | expect(user.to_proto).to eq proto 196 | end 197 | end 198 | 199 | context "when a protobuf message is not configured" do 200 | let(:user) { UnconfiguredUser.new } 201 | 202 | it "raises an exception" do 203 | expect { user.to_proto }.to raise_exception(Protobuf::ActiveRecord::MessageNotDefined) 204 | end 205 | end 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /spec/protobuf/active_record/transformation_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe Protobuf::ActiveRecord::Transformation do 4 | let(:user) { User.new(user_attributes) } 5 | let(:user_attributes) { {first_name: "foo", last_name: "bar", email: "foo@test.co"} } 6 | let(:proto_hash) { {name: "foo bar", email: "foo@test.co"} } 7 | let(:proto) { UserMessage.new(proto_hash) } 8 | 9 | describe "._filter_attribute_fields" do 10 | it "includes fields that have values" do 11 | attribute_fields = User._filter_attribute_fields(proto) 12 | expect(attribute_fields[:email]).to eq proto_hash[:email] 13 | end 14 | 15 | it "filters repeated fields" do 16 | attribute_fields = User._filter_attribute_fields(proto) 17 | expect(attribute_fields.key?(:tags)).to be false 18 | end 19 | 20 | it "includes attributes that aren't fields, but have attribute transformers" do 21 | allow(User).to receive(:_protobuf_attribute_transformers).and_return(account_id: :fetch_account_id) 22 | attribute_fields = User._filter_attribute_fields(proto) 23 | expect(attribute_fields.key?(:account_id)).to be true 24 | end 25 | 26 | it "includes fields that aren't attributes, but have attribute transformers" do 27 | attribute_fields = User._filter_attribute_fields(proto) 28 | expect(attribute_fields.key?(:password)).to be true 29 | end 30 | end 31 | 32 | describe "._protobuf_convert_fields_to_attributes" do 33 | context "when the given field's corresponding column type is :date" do 34 | let(:date) { Date.current } 35 | let(:value) { date.to_time.to_i } 36 | 37 | before { 38 | allow(User).to receive(:_protobuf_date_datetime_time_or_timestamp_column?).and_return(true) 39 | allow(User).to receive(:_protobuf_date_column?).and_return(true) 40 | } 41 | 42 | it "converts the given value to a Date object" do 43 | expect(User._protobuf_convert_fields_to_attributes(:foo_date, value)).to eq date 44 | end 45 | end 46 | 47 | context "when given field's corresponding the column type is :datetime" do 48 | let(:datetime) { DateTime.current } 49 | let(:value) { datetime.to_i } 50 | 51 | before { 52 | allow(User).to receive(:_protobuf_date_datetime_time_or_timestamp_column?).and_return(true) 53 | allow(User).to receive(:_protobuf_datetime_column?).and_return(true) 54 | } 55 | 56 | it "converts the given value to a DateTime object" do 57 | expect(User._protobuf_convert_fields_to_attributes(:foo_datetime, value)).to be_a(DateTime) 58 | end 59 | 60 | it "converts the given value to a DateTime object of the same value" do 61 | expect(User._protobuf_convert_fields_to_attributes(:foo_datetime, value)).to be_within(1).of(datetime) 62 | end 63 | end 64 | 65 | context "when given field's corresponding the column type is :time" do 66 | let(:time) { Time.current } 67 | let(:value) { time.to_i } 68 | 69 | before { 70 | allow(User).to receive(:_protobuf_date_datetime_time_or_timestamp_column?).and_return(true) 71 | allow(User).to receive(:_protobuf_time_column?).and_return(true) 72 | } 73 | 74 | it "converts the given value to a Time object" do 75 | expect(User._protobuf_convert_fields_to_attributes(:foo_time, value)).to be_a(Time) 76 | end 77 | 78 | it "converts the given value to a Time object of the same value" do 79 | expect(User._protobuf_convert_fields_to_attributes(:foo_time, value)).to be_within(1).of(time) 80 | end 81 | end 82 | 83 | context "when given field's corresponding the column type is :timestamp" do 84 | let(:time) { Time.current } 85 | let(:value) { time.to_i } 86 | 87 | before { 88 | allow(User).to receive(:_protobuf_date_datetime_time_or_timestamp_column?).and_return(true) 89 | allow(User).to receive(:_protobuf_timestamp_column?).and_return(true) 90 | } 91 | 92 | it "converts the given value to a Time object" do 93 | expect(User._protobuf_convert_fields_to_attributes(:foo_time, value)).to be_a(Time) 94 | end 95 | 96 | it "converts the given value to a Time object of the same value" do 97 | expect(User._protobuf_convert_fields_to_attributes(:foo_timestamp, value)).to be_within(1).of(time) 98 | end 99 | end 100 | 101 | context "when no conversion is necessary" do 102 | let(:value) { "Foo" } 103 | 104 | it "returns the given value" do 105 | expect(User._protobuf_convert_fields_to_attributes(:foo, value)).to eq value 106 | end 107 | end 108 | end 109 | 110 | describe ".attributes_from_proto" do 111 | let(:callable) { lambda { |_proto| 1 } } 112 | let(:transformer) { ::Protobuf::ActiveRecord::Transformer.new(callable) } 113 | 114 | context "when a transformer is defined for the attribute" do 115 | it "transforms the field value" do 116 | attribute_fields = User.attributes_from_proto(proto) 117 | expect(attribute_fields[:first_name]).to eq user_attributes[:first_name] 118 | end 119 | end 120 | 121 | context "when a transformer is a callable that returns nil" do 122 | let(:callable) { lambda { |_proto| } } 123 | 124 | before do 125 | transformers = User._protobuf_attribute_transformers 126 | allow(User).to receive(:_protobuf_attribute_transformers).and_return( 127 | {account_id: transformer}.merge(transformers) 128 | ) 129 | end 130 | 131 | it "does not set the attribute" do 132 | attribute_fields = User.attributes_from_proto(proto) 133 | expect(attribute_fields).to eq user_attributes 134 | end 135 | end 136 | 137 | context "when the transformer has a nullify_on option" do 138 | let(:callable) { lambda { |_proto| } } 139 | let(:transformer) { ::Protobuf::ActiveRecord::Transformer.new(callable, nullify_on: :account_id) } 140 | let(:proto_hash) { {name: "foo bar", email: "foo@test.co", nullify: [:account_id]} } 141 | 142 | before do 143 | transformers = User._protobuf_attribute_transformers 144 | allow(User).to receive(:_protobuf_attribute_transformers).and_return( 145 | {account_id: transformer}.merge(transformers) 146 | ) 147 | end 148 | 149 | it "does not set the attribute" do 150 | attribute_fields = User.attributes_from_proto(proto) 151 | expect(attribute_fields).to include(account_id: nil) 152 | end 153 | end 154 | 155 | context "when a transformer is a callable that returns a value" do 156 | before do 157 | transformers = User._protobuf_attribute_transformers 158 | allow(User).to receive(:_protobuf_attribute_transformers).and_return( 159 | {account_id: transformer}.merge(transformers) 160 | ) 161 | end 162 | 163 | it "sets the attribute" do 164 | attribute_fields = User.attributes_from_proto(proto) 165 | expect(attribute_fields).to eq user_attributes.merge(account_id: 1) 166 | end 167 | end 168 | 169 | context "when a transformer is not defined for the attribute" do 170 | before { 171 | allow(User).to receive(:_protobuf_convert_fields_to_attributes) do |_key, value| 172 | value 173 | end 174 | } 175 | 176 | it "converts the field value" do 177 | attribute_fields = User.attributes_from_proto(proto) 178 | expect(attribute_fields).to eq user_attributes 179 | end 180 | end 181 | end 182 | 183 | describe ".attribute_from_proto" do 184 | context "when the given transformer is a symbol" do 185 | let(:callable) { lambda { |_value| User.__send__(:extract_first_name) } } 186 | 187 | before { User.attribute_from_proto :first_name, :extract_first_name } 188 | 189 | it "creates a callable method object from the converter" do 190 | expect(User).to receive(:extract_first_name) 191 | User._protobuf_attribute_transformers[:first_name].call(1) 192 | end 193 | end 194 | 195 | context "when the given transformer is not callable" do 196 | it "raises an exception" do 197 | expect { User.attribute_from_proto :name, nil }.to raise_exception(Protobuf::ActiveRecord::AttributeTransformerError) 198 | end 199 | end 200 | 201 | context "when the given transformer is callable" do 202 | let(:callable) { lambda { |_proto| } } 203 | 204 | before { allow(User).to receive(:_protobuf_attribute_transformers).and_return({}) } 205 | 206 | it "adds the given converter to the list of protobuf field transformers" do 207 | User.attribute_from_proto :account_id, callable 208 | expect(User._protobuf_attribute_transformers[:account_id].callable).to eq callable 209 | end 210 | end 211 | end 212 | 213 | describe ".convert_int64_to_date" do 214 | let(:date) { Date.current } 215 | let(:int64) { date.to_time.to_i } 216 | 217 | it "initializes a new Date object from the value" do 218 | travel_to(Date.current) do 219 | expect(User.convert_int64_to_date(int64)).to eq date 220 | end 221 | end 222 | end 223 | 224 | describe ".convert_int64_to_datetime" do 225 | let(:datetime) { DateTime.current } 226 | let(:int64) { datetime.to_i } 227 | 228 | it "initializes a new DateTime object from the value" do 229 | travel_to(DateTime.current) do 230 | expected_datetime = Time.at(datetime.to_i) 231 | converted_datetime = User.convert_int64_to_datetime(int64) 232 | expect(converted_datetime).to eq expected_datetime 233 | end 234 | end 235 | end 236 | 237 | describe ".convert_int64_to_time" do 238 | let(:time) { Time.current } 239 | let(:int64) { time.to_time.to_i } 240 | 241 | it "initializes a new Time object from the value" do 242 | travel_to(Time.current) do 243 | expect(User.convert_int64_to_time(int64)).to be_within(1).of(time) 244 | end 245 | end 246 | end 247 | 248 | describe "#attributes_from_proto" do 249 | it "gets attributes from the given protobuf message" do 250 | expect(User).to receive(:attributes_from_proto).with(proto) 251 | user.attributes_from_proto(proto) 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /spec/protobuf/active_record/transformer_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | RSpec.describe ::Protobuf::ActiveRecord::Transformer do 4 | let(:callable) { lambda { |proto| proto.name } } 5 | let(:proto) { ::UserMessage.new(name: "test", nullify: ["name"]) } 6 | let(:options) { {} } 7 | 8 | subject { described_class.new(callable, options) } 9 | 10 | describe "#call" do 11 | it "calls the callable" do 12 | result = subject.call(proto) 13 | expect(result).to eq("test") 14 | end 15 | end 16 | 17 | describe "#nullify?" do 18 | context "no nullify_on set" do 19 | it "returns false" do 20 | expect(subject.nullify?(proto)).to eq(false) 21 | end 22 | end 23 | 24 | context "nullify_on name" do 25 | let(:options) { {nullify_on: :name} } 26 | 27 | context "invalid message" do 28 | let(:proto) { ::UserSearchMessage.new } 29 | 30 | it "returns false" do 31 | expect(subject.nullify?(proto)).to eq(false) 32 | end 33 | end 34 | 35 | context "valid message" do 36 | it "returns true" do 37 | expect(subject.nullify?(proto)).to eq(true) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | SimpleCov.start do 5 | add_filter "/spec/" 6 | end 7 | 8 | require "protobuf-activerecord" 9 | 10 | require "active_support/testing/time_helpers" 11 | 12 | require "support/db" 13 | require "support/models" 14 | require "support/protobuf/messages.pb" 15 | 16 | # Silence protobuf"s logger 17 | Protobuf::Logging.logger.level = ::Logger::FATAL 18 | 19 | RSpec.configure do |config| 20 | config.include ActiveSupport::Testing::TimeHelpers 21 | 22 | # Enable flags like --only-failures and --next-failure 23 | config.example_status_persistence_file_path = ".rspec_status" 24 | 25 | # Disable RSpec exposing methods globally on `Module` and `main` 26 | config.disable_monkey_patching! 27 | 28 | config.expect_with :rspec do |c| 29 | c.syntax = :expect 30 | end 31 | 32 | # Verify the existence of any stubbed methods 33 | config.mock_with :rspec do |c| 34 | c.verify_partial_doubles = true 35 | end 36 | 37 | # Turn deprecation warnings into errors with full backtrace. 38 | config.raise_errors_for_deprecations! 39 | end 40 | -------------------------------------------------------------------------------- /spec/support/db.rb: -------------------------------------------------------------------------------- 1 | require "support/db/setup" 2 | -------------------------------------------------------------------------------- /spec/support/db/setup.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | 3 | ActiveRecord::Base.establish_connection( 4 | adapter: "sqlite3", 5 | database: "spec/test.db" 6 | ) 7 | 8 | ActiveRecord::Base.connection.data_sources.each do |table| 9 | ActiveRecord::Base.connection.drop_table(table) 10 | end 11 | 12 | ActiveRecord::Schema.define(version: 1) do 13 | create_table :photos do |t| 14 | t.string :url 15 | t.integer :user_id 16 | 17 | t.timestamps null: false 18 | end 19 | 20 | create_table :users do |t| 21 | t.string :guid 22 | t.string :first_name 23 | t.string :last_name 24 | t.string :email 25 | t.integer :account_id 26 | 27 | t.timestamps null: false 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/definitions/messages.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | message PhotoMessage { 4 | string url = 1; 5 | int64 user_guid = 2; 6 | } 7 | 8 | message UserMessage { 9 | string guid = 1; 10 | string name = 2; 11 | string email = 3; 12 | string email_domain = 4 [deprecated = true]; 13 | string password = 5; 14 | repeated string nullify = 6; 15 | repeated PhotoMessage photos = 7; 16 | int64 created_at = 8; 17 | int64 updated_at = 9; 18 | } 19 | 20 | message UserSearchMessage { 21 | repeated string guid = 1; 22 | repeated string email = 2; 23 | } 24 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | require "support/models/photo" 2 | require "support/models/user" 3 | -------------------------------------------------------------------------------- /spec/support/models/photo.rb: -------------------------------------------------------------------------------- 1 | class Photo < ActiveRecord::Base 2 | include Protobuf::ActiveRecord::Model 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | include Protobuf::ActiveRecord::Model 3 | 4 | attr_accessor :password 5 | 6 | has_many :photos 7 | 8 | accepts_nested_attributes_for :photos 9 | 10 | scope :by_guid, lambda { |*guids| where(guid: guids) } 11 | scope :by_email, lambda { |*emails| where(email: emails) } 12 | 13 | protobuf_fields except: :photos 14 | 15 | attribute_from_proto :first_name, :extract_first_name 16 | attribute_from_proto :last_name, :extract_last_name 17 | attribute_from_proto :password, lambda { |proto| proto.password! } 18 | 19 | field_from_record :email_domain, lambda { |record| record.email.split("@").last } 20 | field_from_record :password, :password_transformer 21 | 22 | def self.extract_first_name(proto) 23 | if proto.field?(:name) 24 | names = proto.name.split(" ") 25 | first_name = names.first 26 | end 27 | 28 | first_name 29 | end 30 | 31 | def self.extract_last_name(proto) 32 | if proto.field?(:name) 33 | names = proto.name.split(" ") 34 | names.shift # Drop the first name 35 | last_name = names.join(" ") 36 | end 37 | 38 | last_name 39 | end 40 | 41 | def self.password_transformer(user) 42 | # Simple way to test field transformers that call methods 43 | user.password 44 | end 45 | 46 | def token 47 | "key" 48 | end 49 | 50 | def name 51 | "#{first_name} #{last_name}" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/support/protobuf/messages.pb.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # This file is auto-generated. DO NOT EDIT! 3 | # 4 | require "protobuf" 5 | 6 | ## 7 | # Message Classes 8 | # 9 | class PhotoMessage < ::Protobuf::Message; end 10 | 11 | class UserMessage < ::Protobuf::Message; end 12 | 13 | class UserSearchMessage < ::Protobuf::Message; end 14 | 15 | ## 16 | # Message Fields 17 | # 18 | class PhotoMessage 19 | optional :string, :url, 1 20 | optional :int64, :user_guid, 2 21 | end 22 | 23 | class UserMessage 24 | optional :string, :guid, 1 25 | optional :string, :name, 2 26 | optional :string, :email, 3 27 | optional :string, :email_domain, 4, deprecated: true 28 | optional :string, :password, 5 29 | repeated :string, :nullify, 6 30 | repeated ::PhotoMessage, :photos, 7 31 | optional :int64, :created_at, 8 32 | optional :int64, :updated_at, 9 33 | end 34 | 35 | class UserSearchMessage 36 | repeated :string, :guid, 1 37 | repeated :string, :email, 2 38 | end 39 | -------------------------------------------------------------------------------- /spec/support/time_helpers.rb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liveh2o/protobuf-activerecord/b137c76d9e425a12b37cd7cd5c25f6a344ed423e/spec/support/time_helpers.rb --------------------------------------------------------------------------------