├── .git-blame-ignore-revs
├── .github
├── CODEOWNERS
└── workflows
│ ├── ci.yml
│ ├── publish.yml
│ ├── rails_main_testing.yml
│ └── ruby-gem-publication.yml
├── .gitignore
├── CHANGELOG.md
├── Gemfile
├── LICENSE
├── README.md
├── Rakefile
├── benchmark
└── read.rb
├── gemfiles
├── common.rb
├── rails6.1.gemfile
├── rails7.0.gemfile
├── rails7.1.gemfile
├── rails7.2.gemfile
└── rails_main.gemfile
├── lib
├── property_sets.rb
└── property_sets
│ ├── action_view_extension.rb
│ ├── active_record_extension.rb
│ ├── casting.rb
│ ├── delegator.rb
│ ├── property_set_model.rb
│ └── version.rb
├── property_sets.gemspec
└── spec
├── casting_spec.rb
├── delegator_spec.rb
├── inheritance_spec.rb
├── property_sets_spec.rb
├── spec_helper.rb
├── support
├── acts_like_an_integer.rb
├── database.yml
├── database_config.rb
├── database_migrations.rb
└── models.rb
└── view_extensions_spec.rb
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | 0f442b1777212eb5f0574f1d66d8692d7b42c94d
2 | 9f9fbc7e8e711fd3c3f1377c415dffba14d08d48
3 | fa5717a68557d679b76f8a485466bd3192c06ad7
4 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @zendesk/database-gem-owners
2 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - master
8 | workflow_dispatch:
9 |
10 | jobs:
11 | specs:
12 | runs-on: ubuntu-latest
13 |
14 | name: Ruby ${{ matrix.ruby-version }}, ${{ matrix.gemfile }}, LCH ${{ matrix.legacy_connection_handling }}
15 |
16 | strategy:
17 | fail-fast: false
18 | matrix:
19 | ruby-version:
20 | - '3.1'
21 | - '3.2'
22 | - '3.3'
23 | - '3.4'
24 | gemfile:
25 | - rails6.1
26 | - rails7.0
27 | - rails7.1
28 | - rails7.2
29 | legacy_connection_handling:
30 | - 'true'
31 | - 'false'
32 | include:
33 | - {ruby-version: '3.3', gemfile: rails_main, legacy_connection_handling: 'false'}
34 | env:
35 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
36 | LEGACY_CONNECTION_HANDLING: ${{ matrix.legacy_connection_handling }}
37 | steps:
38 | - uses: actions/checkout@v4
39 | - name: Set up Ruby
40 | uses: ruby/setup-ruby@v1
41 | with:
42 | ruby-version: ${{ matrix.ruby-version }}
43 | bundler-cache: true
44 | - name: RSpec
45 | run: bundle exec rspec
46 |
47 | specs_successful:
48 | name: Specs passing?
49 | needs: specs
50 | if: always()
51 | runs-on: ubuntu-latest
52 | steps:
53 | - run: |
54 | if ${{ needs.specs.result == 'success' }}
55 | then
56 | echo "All specs pass"
57 | else
58 | echo "Some specs failed"
59 | false
60 | fi
61 |
62 | lint:
63 | runs-on: ubuntu-latest
64 | steps:
65 | - uses: actions/checkout@v4
66 | - name: Set up Ruby
67 | uses: ruby/setup-ruby@v1
68 | with:
69 | ruby-version: "3.1"
70 | bundler-cache: true
71 | - run: bundle exec rake standard
72 |
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish to RubyGems.org
2 |
3 | on:
4 | push:
5 | branches: main
6 | paths: lib/property_sets/version.rb
7 | workflow_dispatch:
8 |
9 | jobs:
10 | publish:
11 | runs-on: ubuntu-latest
12 | environment: rubygems-publish
13 | if: github.repository_owner == 'zendesk'
14 | permissions:
15 | id-token: write
16 | contents: write
17 | steps:
18 | - uses: actions/checkout@v4
19 | - name: Set up Ruby
20 | uses: ruby/setup-ruby@v1
21 | with:
22 | bundler-cache: false
23 | ruby-version: "3.4"
24 | - name: Install dependencies
25 | run: bundle install
26 | - uses: rubygems/release-gem@v1
27 |
--------------------------------------------------------------------------------
/.github/workflows/rails_main_testing.yml:
--------------------------------------------------------------------------------
1 | name: Test against Rails main
2 |
3 | on:
4 | schedule:
5 | - cron: "0 0 * * *" # Run every day at 00:00 UTC
6 | workflow_dispatch:
7 |
8 | jobs:
9 | specs:
10 | runs-on: ubuntu-latest
11 |
12 | name: Ruby ${{ matrix.ruby-version }}, ${{ matrix.gemfile }}
13 |
14 | strategy:
15 | fail-fast: false
16 | matrix:
17 | ruby-version:
18 | - '3.4'
19 | gemfile:
20 | - rails_main
21 | env:
22 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
23 | steps:
24 | - uses: zendesk/checkout@v4
25 | - name: Set up Ruby
26 | uses: zendesk/setup-ruby@v1
27 | with:
28 | ruby-version: ${{ matrix.ruby-version }}
29 | bundler-cache: true
30 | - name: RSpec
31 | run: bundle exec rspec
32 |
--------------------------------------------------------------------------------
/.github/workflows/ruby-gem-publication.yml:
--------------------------------------------------------------------------------
1 | name: Ruby Gem Publish
2 |
3 | on:
4 | push:
5 | tags: v*
6 |
7 | jobs:
8 | call-workflow:
9 | uses: zendesk/gw/.github/workflows/ruby-gem-publication.yml@main
10 | secrets:
11 | RUBY_GEMS_API_KEY: ${{ secrets.RUBY_GEMS_API_KEY }}
12 | RUBY_GEMS_TOTP_DEVICE: ${{ secrets.RUBY_GEMS_TOTP_DEVICE }}
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | Gemfile.lock
2 | gemfiles/*.lock
3 | test/*.log
4 | .rvmrc
5 | coverage
6 | rdoc
7 | doc
8 | .yardoc
9 | .bundle
10 | pkg
11 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [3.13.0] - 2024-07-08
11 |
12 | * Removed upper limit on Rails, added testing for Rails main.
13 | * Dropped support for Ruby < 3.1.
14 | * Dropped support for Rails < 6.1.
15 |
16 | ## [3.12.0] - 2023-12-12
17 |
18 | * Added support for Rails 7.1
19 |
20 | ## [3.11.0] - 2023-11-08
21 |
22 | * Property tables can now live on a separate database to their parent models. This is achieved, on a per-model basis, by configuring the connection class that will be used by property sets. e.g. set `self.property_sets_connection_class = Foo` on the model to instruct `property_sets` to use `Foo`'s database connection when looking for the property sets tables.
23 |
24 | ## [3.10.0] - 2023-09-18
25 |
26 | * Property models now inherit from the same parent as their owners (this unblocks [using multiple databases natively in Rails](https://guides.rubyonrails.org/active_record_multiple_databases.html)).
27 | * Dropped support for Rails 5.
28 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | eval_gemfile "gemfiles/rails6.1.gemfile"
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Property sets [](https://github.com/zendesk/property_sets/actions?query=workflow%3ACI)
2 |
3 | This gem is a way for you to use a basic "key/value" store for storing attributes for a given model in a relational fashion where there's a row per attribute. Alternatively you'd need to add a new column per attribute to your main table, or serialize the attributes and their values using the [Active Record Store](https://api.rubyonrails.org/classes/ActiveRecord/Store.html).
4 |
5 | ## Description
6 |
7 | You configure the allowed stored properties by specifying these in the model:
8 |
9 | ```ruby
10 | class Account < ActiveRecord::Base
11 | property_set :settings do
12 | property :version, :default => "v1.0"
13 | property :featured, :protected => true
14 | property :activated
15 | end
16 |
17 | property_set :texts do
18 | property :epilogue
19 | end
20 | end
21 | ```
22 |
23 | The declared properties can then be accessed runtime via the defined association:
24 |
25 | ```ruby
26 | # Return the value of the version record for this account, or the default value if not set
27 | account.settings.version
28 |
29 | # Update the version record with given value
30 | account.settings.version = "v1.1"
31 |
32 | # Query the truth value of the property
33 | account.settings.featured?
34 |
35 | # Short hand for setting one or more values
36 | account.settings.set(:version => "v1.2", :activated => true)
37 |
38 | # Short hand for getting a hash with pairs for each key argument
39 | account.settings.get([:version, :activated])
40 | ```
41 |
42 | You can also forward read, write and query methods to the properties with `PropertySets::Delegator`.
43 |
44 | ```ruby
45 | class Account < ActiveRecord::Base
46 | include PropertySets::Delegator
47 | delegate_to_property_set :settings, :is_open => :open, :same => :same
48 | end
49 |
50 | account.open #=> account.settings.is_open
51 | ```
52 |
53 | These classes and their subclasses will inherit specified properties.
54 |
55 | ### Validations
56 |
57 | Property sets supports standard AR validations, although in a somewhat manual fashion.
58 |
59 | ```ruby
60 | class Account < ActiveRecord::Base
61 | property_set :settings do
62 | property :version, :default => "v1.0"
63 | property :featured, :protected => true
64 |
65 | validates_format_of :value, :with => /v\d+\.\d+/, :message => "of version is invalid",
66 | :if => Proc.new { |r| r.name.to_sym == :version }
67 | end
68 | end
69 | ```
70 |
71 | On `account.save` this will result in an error record being added. You can also inspect the
72 | setting record using `account.settings.version_record`
73 |
74 | ### Bulk operations
75 |
76 | Stored properties can also be updated with the update_attributes and update_attributes! methods by
77 | enabling nested attributes. Like this (from the test cases):
78 |
79 | ```ruby
80 | @account.texts_attributes = [
81 | { :name => "foo", :value => "1" },
82 | { :name => "bar", :value => "0" }
83 | ]
84 | ```
85 |
86 | And for existing records:
87 |
88 | ```ruby
89 | @account.update_attributes!(:texts_attributes => [
90 | { :id => @account.texts.foo.id, :name => "foo", :value => "0" },
91 | { :id => @account.texts.bar.id, :name => "bar", :value => "1" }
92 | ])
93 | ```
94 |
95 | Using nested attributes is subject to implementing your own security measures for mass update assignments.
96 | Alternatively, it is possible to use a custom hash structure:
97 |
98 | ```ruby
99 | params = {
100 | :settings => { :version => "v4.0", :featured => "1" },
101 | :texts => { :epilogue => "Wibble wobble" }
102 | }
103 |
104 | @account.update_attributes(params)
105 | ```
106 |
107 | The above will not update `featured` as this has the protected flag set and is hence protected from
108 | mass updates.
109 |
110 | ### View helpers
111 |
112 | We support a couple of convenience mechanisms for building forms and putting the values into the above hash structure. So far, only support check boxes and radio buttons:
113 |
114 | ```erb
115 | <% form_for(:account, :html => { :method => :put }) do |f| %>
116 |
<%= f.property_set(:settings).check_box :activated %> Activated?
117 | <%= f.property_set(:settings).radio_button :hot, "yes" %> Hot
118 | <%= f.property_set(:settings).radio_button :not, "no" %> Not
119 | <%= f.property_set(:settings).select :level, [["One", 1], ["Two", 2]] %>
120 | <% end %>
121 | ```
122 |
123 | ## Installation
124 |
125 | Install the gem in your rails project by putting it in your Gemfile:
126 |
127 | ```
128 | gem "property_sets"
129 | ```
130 |
131 | Also remember to create the storage table(s), if for example you are going to be using this with an accounts model and a "settings" property set, you can define the table like:
132 |
133 | ```ruby
134 | create_table :account_settings do |t|
135 | t.integer :account_id, :null => false
136 | t.string :name, :null => false
137 | t.string :value
138 | t.timestamps
139 | end
140 |
141 | add_index :account_settings, [ :account_id, :name ], :unique => true
142 | ```
143 |
144 | If you would like to serialize larger objects into your property sets, you can use a `TEXT` column type for value like this:
145 |
146 | ```ruby
147 | create_table :account_settings do |t|
148 | t.integer :account_id, :null => false
149 | t.string :name, :null => false
150 | t.text :value
151 | t.timestamps
152 | end
153 |
154 | add_index :account_settings, [ :account_id, :name ], :unique => true
155 | ```
156 |
157 | ### Releasing a new version
158 | A new version is published to RubyGems.org every time a change to `version.rb` is pushed to the `main` branch.
159 | In short, follow these steps:
160 | 1. Update `version.rb`,
161 | 2. update version in all `Gemfile.lock` files,
162 | 3. merge this change into `main`, and
163 | 4. look at [the action](https://github.com/zendesk/property_sets/actions/workflows/publish.yml) for output.
164 |
165 | To create a pre-release from a non-main branch:
166 | 1. change the version in `version.rb` to something like `1.2.0.pre.1` or `2.0.0.beta.2`,
167 | 2. push this change to your branch,
168 | 3. go to [Actions → “Publish to RubyGems.org” on GitHub](https://github.com/zendesk/property_sets/actions/workflows/publish.yml),
169 | 4. click the “Run workflow” button,
170 | 5. pick your branch from a dropdown.
171 |
172 | ### Storage table(s) on separate databases
173 |
174 | By default, `property_sets` looks for the storage table(s) on the same database as the model. If you need the storage tables to live on a different database you can configure a custom connection class on a per-model basis:
175 |
176 | ``` ruby
177 | class MainConnectionClass < ActiveRecord::Base
178 | self.abstract_class = true
179 |
180 | connects_to(database: { writing: foo })
181 | end
182 |
183 | class SeparateDatabase < ActiveRecord::Base
184 | self.abstract_class = true
185 |
186 | connects_to(database: { writing: bar })
187 | end
188 |
189 | class Account < MainConnectionClass
190 | # Ensure you set this _before_ configuring the property sets.
191 | self.property_sets_connection_class = SeparateDatabase
192 |
193 | property_set :settings do
194 | property :foo
195 | end
196 | end
197 | ```
198 |
199 | In the above example, the `Accounts` table would live on the `foo` database and the storage table(s) will be written to the `bar` database.
200 |
201 | ## Requirements
202 |
203 | * ActiveRecord
204 | * ActiveSupport
205 |
206 | ## License and copyright
207 |
208 | Copyright 2013 Zendesk
209 |
210 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
211 | You may obtain a copy of the License at
212 |
213 | http://www.apache.org/licenses/LICENSE-2.0
214 |
215 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
216 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require "bundler/setup"
2 | require "bundler/gem_tasks"
3 | require "bump/tasks"
4 | require "rspec/core/rake_task"
5 | require "standard/rake"
6 |
7 | task default: [:spec, :standard]
8 |
9 | RSpec::Core::RakeTask.new(:spec)
10 |
--------------------------------------------------------------------------------
/benchmark/read.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path(File.dirname(__FILE__) + "/../test/helper")
2 |
3 | class Account < ActiveRecord::Base
4 | # Benchmark reading from an object with many settings when:
5 | # 1. Settings are undefined and use the default value (empty)
6 | # 2. Settings are defined and use the database value (fully defined)
7 |
8 | # This is a fairly realastic example of an object that has a lot of settings
9 | property_set :benchmark_settings do
10 | # 30 simple objects
11 | 10.times do |i|
12 | property "float_prop_#{i}", type: :float, default: 3.1415
13 | property "int_prop_#{i}", type: :integer, default: 22
14 | property "string_prop_#{i}", type: :string, default: "Sausalito, CA"
15 | end
16 |
17 | # 10 complex
18 | 5.times do |i|
19 | property "datetime_prop_#{i}", type: :datetime, default: Time.now.to_s
20 | property "serialized_prop_#{i}", type: :serialized, default: {"Hello" => "There"}
21 | end
22 |
23 | # 60 booleans
24 | 60.times do |i|
25 | property "boolean_prop_#{i}", type: :boolean, default: true
26 | end
27 | end
28 | end
29 |
30 | class BenchmarkRead < ActiveSupport::TestCase
31 | context "property sets" do
32 | setup do
33 | @account = Account.create(name: "Name")
34 | end
35 |
36 | should "benchmark fully defined settings" do
37 | # Most settings are defined and will come from the database
38 | @account.benchmark_settings.keys.each do |key|
39 | @account.benchmark_settings.build_default(key)
40 | end
41 | @account.save!
42 | @account.reload
43 | assert_equal 100, @account.benchmark_settings.count
44 |
45 | GC.start
46 | full_timing = Benchmark.ms do
47 | 1_000.times do
48 | read_settings(@account)
49 | end
50 | end
51 | puts "Reading fully defined settings: #{full_timing}ms"
52 | end
53 |
54 | should "benchmark defaults" do
55 | assert_equal 0, @account.benchmark_settings.count
56 | # Most settings are undefined and will use the default value
57 | GC.start
58 | empty_timing = Benchmark.ms do
59 | 1_000.times do
60 | read_settings(@account)
61 | end
62 | end
63 | puts "Reading empty settings: #{empty_timing}ms"
64 | end
65 | end
66 |
67 | def read_settings(account)
68 | account.benchmark_settings.float_prop_1
69 | account.benchmark_settings.int_prop_1
70 | account.benchmark_settings.string_prop_1
71 | account.benchmark_settings.datetime_prop_1
72 | account.benchmark_settings.boolean_prop_20?
73 | account.benchmark_settings.boolean_prop_30?
74 | account.benchmark_settings.boolean_prop_40?
75 | account.benchmark_settings.boolean_prop_50?
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/gemfiles/common.rb:
--------------------------------------------------------------------------------
1 | gem "pry-byebug", require: false
2 |
--------------------------------------------------------------------------------
/gemfiles/rails6.1.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gemspec path: "../"
4 |
5 | gem "activerecord", "~> 6.1.0"
6 | gem "actionpack", "~> 6.1.0"
7 | gem "sqlite3", "~> 1.4"
8 |
9 | eval_gemfile "common.rb"
10 | gem "base64"
11 | gem "bigdecimal"
12 | gem "mutex_m"
13 |
--------------------------------------------------------------------------------
/gemfiles/rails7.0.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gemspec path: "../"
4 |
5 | gem "activerecord", "~> 7.0.0"
6 | gem "actionpack", "~> 7.0.0"
7 | gem "sqlite3", "~> 1.4"
8 |
9 | eval_gemfile "common.rb"
10 | gem "base64"
11 | gem "bigdecimal"
12 | gem "mutex_m"
13 |
--------------------------------------------------------------------------------
/gemfiles/rails7.1.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gemspec path: "../"
4 |
5 | gem "activerecord", "~> 7.1.0"
6 | gem "actionpack", "~> 7.1.0"
7 | gem "sqlite3", "~> 1.4"
8 |
9 | eval_gemfile "common.rb"
10 |
--------------------------------------------------------------------------------
/gemfiles/rails7.2.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gemspec path: "../"
4 |
5 | gem "activerecord", "~> 7.2.0"
6 | gem "actionpack", "~> 7.2.0"
7 | gem "sqlite3", "~> 1.4"
8 |
9 | eval_gemfile "common.rb"
10 |
--------------------------------------------------------------------------------
/gemfiles/rails_main.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gemspec path: "../"
4 |
5 | gem "activerecord", github: "rails/rails", branch: "main"
6 | gem "actionpack", github: "rails/rails", branch: "main"
7 | gem "sqlite3", "~> 2"
8 |
9 | eval_gemfile "common.rb"
10 |
--------------------------------------------------------------------------------
/lib/property_sets.rb:
--------------------------------------------------------------------------------
1 | require "property_sets/property_set_model"
2 | require "property_sets/active_record_extension"
3 | require "property_sets/version"
4 |
5 | begin
6 | require "property_sets/action_view_extension"
7 | rescue LoadError
8 | end
9 |
10 | if "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" == "6.1"
11 | ActiveRecord::Base.singleton_class.alias_method :connection_class_for_self, :connection_classes
12 | end
13 |
14 | module PropertySets
15 | def self.ensure_property_set_class(association, owner_class_name)
16 | const_name = "#{owner_class_name.demodulize}#{association.to_s.singularize.camelcase}"
17 | namespace = owner_class_name.deconstantize.safe_constantize || Object
18 |
19 | unless namespace.const_defined?(const_name, false)
20 | property_class = Class.new(parent_for_property_class(namespace, owner_class_name)) do
21 | include PropertySets::PropertySetModel::InstanceMethods
22 | extend PropertySets::PropertySetModel::ClassMethods
23 | end
24 |
25 | namespace.const_set(const_name, property_class)
26 |
27 | property_class.owner_class = owner_class_name
28 | property_class.owner_assoc = association
29 | end
30 |
31 | namespace.const_get(const_name.to_s)
32 | end
33 |
34 | def self.parent_for_property_class(namespace, owner_class_name)
35 | owner_class = namespace.const_get(owner_class_name)
36 |
37 | owner_class.property_sets_connection_class || owner_class.connection_class_for_self
38 | rescue NameError
39 | ::ActiveRecord::Base
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/property_sets/action_view_extension.rb:
--------------------------------------------------------------------------------
1 | require "action_view"
2 |
3 | module ActionView
4 | module Helpers
5 | class FormBuilder
6 | class PropertySetFormBuilderProxy
7 | attr_reader :property_set, :template, :object_name, :object
8 |
9 | def initialize(property_set, template, object_name, object)
10 | @property_set = property_set
11 | @template = template
12 | @object_name = object_name
13 | @object = object
14 | end
15 |
16 | def check_box(property, options = {}, checked_value = "1", unchecked_value = "0")
17 | options = prepare_options(property, options) do |properties|
18 | properties.send(:"#{property}?")
19 | end
20 | template.check_box(object_name, property, options, checked_value, unchecked_value)
21 | end
22 |
23 | def radio_button(property, checked_value = "1", options = {})
24 | options[:id] ||= "#{object_name}_#{property_set}_#{property}_#{checked_value}"
25 | options = prepare_options(property, options) do |properties|
26 | properties.send(property.to_s) == checked_value
27 | end
28 | template.radio_button(object_name, property, checked_value, options)
29 | end
30 |
31 | def text_field(property, options = {})
32 | template.text_field(object_name, property, prepare_id_name(property, options))
33 | end
34 |
35 | def hidden_field(property, options = {})
36 | options = prepare_id_name(property, options)
37 | unless options.key?(:value)
38 | options[:value] = cast_boolean(options[:object].send(property_set).send(property))
39 | end
40 | template.hidden_field(object_name, property, options)
41 | end
42 |
43 | def select(property, choices, options = {}, html_options = {})
44 | options = prepare_id_name(property, options)
45 | current_value = options[:object].send(property_set).send(property)
46 | template.select("#{object_name}[#{property_set}]", property, choices, {selected: current_value}, html_options)
47 | end
48 |
49 | private
50 |
51 | def prepare_id_name(property, options)
52 | throw "Invalid options type #{options.inspect}" unless options.is_a?(Hash)
53 |
54 | options.clone.tap do |prepared_options|
55 | prepared_options[:object] = object || fetch_target_object
56 | prepared_options[:id] ||= "#{object_name}_#{property_set}_#{property}"
57 | prepared_options[:name] = "#{object_name}[#{property_set}][#{property}]"
58 | end
59 | end
60 |
61 | def fetch_target_object
62 | instance = template.instance_variable_get(:"@#{object_name}")
63 |
64 | throw "No @#{object_name} in scope" if instance.nil?
65 | throw "The property_set_check_box only works on models with property set #{property_set}" unless instance.respond_to?(property_set)
66 |
67 | instance
68 | end
69 |
70 | def prepare_options(property, options, &block)
71 | options = prepare_id_name(property, options)
72 | options[:checked] = yield(options[:object].send(property_set))
73 | options
74 | end
75 |
76 | def cast_boolean(value)
77 | case value
78 | when TrueClass then "1"
79 | when FalseClass then "0"
80 | else value
81 | end
82 | end
83 | end
84 |
85 | def property_set(identifier)
86 | PropertySetFormBuilderProxy.new(identifier, @template, object_name, object)
87 | end
88 | end
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/property_sets/active_record_extension.rb:
--------------------------------------------------------------------------------
1 | require "active_record"
2 | require "property_sets/casting"
3 |
4 | module PropertySets
5 | module ActiveRecordExtension
6 | module ClassMethods
7 | attr_accessor :property_sets_connection_class
8 |
9 | def property_set(association, options = {}, &block)
10 | unless include?(PropertySets::ActiveRecordExtension::InstanceMethods)
11 | send(:prepend, PropertySets::ActiveRecordExtension::InstanceMethods)
12 | cattr_accessor :property_set_index
13 | self.property_set_index = Set.new
14 | end
15 |
16 | raise "Invalid association name, letters only" unless /[a-z]+/.match?(association.to_s)
17 | exists = property_set_index.include?(association)
18 |
19 | property_set_index << association
20 |
21 | # eg AccountSetting - this IS idempotent
22 | property_class = PropertySets.ensure_property_set_class(
23 | association,
24 | options.delete(:owner_class_name) || name
25 | )
26 |
27 | # eg property :is_awesome
28 | property_class.instance_eval(&block) if block
29 |
30 | tb_name = options.delete :table_name
31 | property_class.table_name = tb_name if tb_name
32 |
33 | hash_opts = {
34 | class_name: property_class.name,
35 | autosave: true,
36 | dependent: :destroy,
37 | inverse_of: name.demodulize.underscore.to_sym
38 | }.merge(options)
39 |
40 | # TODO: should check options are compatible? warn? raise?
41 | reflection = reflections[association.to_s] # => ActiveRecord::Reflection::HasManyReflection
42 | reflection.options.merge! options if reflection && !options.empty?
43 |
44 | unless exists # makes has_many idempotent...
45 | has_many association, **hash_opts do
46 | # keep this damn block! -- creates association_module below
47 | end
48 | end
49 |
50 | # stolen/adapted from AR's collection_association.rb #define_extensions
51 |
52 | module_name = "#{association.to_s.camelize}AssociationExtension"
53 | association_module = const_get module_name
54 |
55 | association_module.module_eval do
56 | include PropertySets::ActiveRecordExtension::AssociationExtensions
57 |
58 | property_class.keys.each do |key|
59 | raise "Invalid property key #{key}" if respond_to?(key)
60 |
61 | # Reports the coerced truth value of the property
62 | define_method :"#{key}?" do
63 | type = property_class.type(key)
64 | value = lookup_value(type, key)
65 | !["false", "0", "", "off", "n"].member?(value.to_s.downcase)
66 | end
67 |
68 | # Returns the value of the property
69 | define_method key.to_s do
70 | type = property_class.type(key)
71 | lookup_value(type, key)
72 | end
73 |
74 | # Assigns a new value to the property
75 | define_method :"#{key}=" do |value|
76 | instance = lookup(key)
77 | instance.value = PropertySets::Casting.write(property_class.type(key), value)
78 | instance.value
79 | end
80 |
81 | define_method :"#{key}_record" do
82 | lookup(key)
83 | end
84 | end
85 |
86 | define_method :property_serialized? do |key|
87 | property_class.type(key) == :serialized
88 | end
89 | end
90 | end
91 | end
92 |
93 | module AssociationExtensions
94 | # Accepts an array of names as strings or symbols and returns a hash.
95 | def get(keys = [])
96 | property_keys = if keys.empty?
97 | association_class.keys
98 | else
99 | association_class.keys & keys.map(&:to_s)
100 | end
101 |
102 | property_pairs = property_keys.flat_map do |name|
103 | value = lookup_value(association_class.type(name), name)
104 | [name, value]
105 | end
106 | HashWithIndifferentAccess[*property_pairs]
107 | end
108 |
109 | # Accepts a name value pair hash { :name => 'value', :pairs => true } and builds a property for each key
110 | def set(property_pairs, with_protection = false)
111 | property_pairs.keys.each do |name|
112 | record = lookup(name)
113 | if with_protection && record.protected?
114 | association_class.logger.warn("Someone tried to update the protected #{name} property to #{property_pairs[name]}")
115 | else
116 | send(:"#{name}=", property_pairs[name])
117 | end
118 | end
119 | end
120 |
121 | def save(...)
122 | each { |p| p.save(...) }
123 | end
124 |
125 | def save!(...)
126 | each { |p| p.save!(...) }
127 | end
128 |
129 | def protected?(arg)
130 | lookup(arg).protected?
131 | end
132 |
133 | def enable(arg)
134 | send(:"#{arg}=", "1")
135 | end
136 |
137 | def disable(arg)
138 | send(:"#{arg}=", "0")
139 | end
140 |
141 | def build_default(arg)
142 | build(name: arg.to_s, value: association_class.raw_default(arg))
143 | end
144 |
145 | def lookup_without_default(arg)
146 | detect { |property| property.name.to_sym == arg.to_sym }
147 | end
148 |
149 | def lookup_value(type, key)
150 | serialized = property_serialized?(key)
151 |
152 | if (instance = lookup_without_default(key))
153 | instance.value_serialized = serialized
154 | PropertySets::Casting.read(type, instance.value)
155 | else
156 | value = association_class.default(key)
157 | if serialized
158 | PropertySets::Casting.deserialize(value)
159 | else
160 | PropertySets::Casting.read(type, value)
161 | end
162 | end
163 | end
164 |
165 | # The finder method which returns the property if present, otherwise a new instance with defaults
166 | def lookup(arg)
167 | instance = lookup_without_default(arg)
168 | instance ||= build_default(arg)
169 | instance.value_serialized = property_serialized?(arg)
170 |
171 | owner = proxy_association.owner
172 |
173 | instance.send(:"#{association_class.owner_class_sym}=", owner) if owner.new_record?
174 | instance
175 | end
176 |
177 | # This finder method returns the property if present, otherwise a new instance with the default value.
178 | # It does not have the side effect of adding a new setting object.
179 | def lookup_or_default(arg)
180 | instance = lookup_without_default(arg)
181 | instance ||= association_class.new(value: association_class.raw_default(arg))
182 | instance.value_serialized = property_serialized?(arg)
183 | instance
184 | end
185 |
186 | def association_class
187 | @association_class ||= proxy_association.klass
188 | end
189 | end
190 |
191 | module InstanceMethods
192 | def update(attributes)
193 | update_property_set_attributes(attributes)
194 | super
195 | end
196 | alias_method :update_attributes, :update
197 |
198 | def update!(attributes)
199 | update_property_set_attributes(attributes)
200 | super
201 | end
202 | alias_method :update_attributes!, :update!
203 |
204 | def update_property_set_attributes(attributes)
205 | if attributes && self.class.property_set_index.any?
206 | self.class.property_set_index.each do |property_set|
207 | if (property_set_hash = attributes.delete(property_set))
208 | send(property_set).set(property_set_hash, true)
209 | end
210 | end
211 | end
212 | end
213 |
214 | def update_columns(attributes)
215 | if delegated_property_sets?
216 | attributes = attributes.reject { |k, _| self.class.delegated_property_set_attributes.include?(k.to_s) }
217 | end
218 |
219 | super
220 | end
221 |
222 | private
223 |
224 | def delegated_property_sets?
225 | self.class.respond_to?(:delegated_property_set_attributes)
226 | end
227 |
228 | def attributes_for_create(attribute_names)
229 | super(filter_delegated_property_set_attributes(attribute_names))
230 | end
231 |
232 | def attributes_for_update(attribute_names)
233 | super(filter_delegated_property_set_attributes(attribute_names))
234 | end
235 |
236 | def filter_delegated_property_set_attributes(attribute_names)
237 | if delegated_property_sets?
238 | return attribute_names - self.class.delegated_property_set_attributes.to_a
239 | end
240 | attribute_names
241 | end
242 | end
243 | end
244 | end
245 |
246 | ActiveRecord::Base.extend PropertySets::ActiveRecordExtension::ClassMethods
247 |
--------------------------------------------------------------------------------
/lib/property_sets/casting.rb:
--------------------------------------------------------------------------------
1 | require "json"
2 |
3 | module PropertySets
4 | module Casting
5 | FALSE_VALUES = ["false", "0", "", "off", "n"]
6 |
7 | class << self
8 | def read(type, value)
9 | return nil if value.nil?
10 |
11 | case type
12 | when :string
13 | value
14 | when :datetime
15 | Time.parse(value).in_time_zone
16 | when :float
17 | value.to_f
18 | when :integer
19 | value.to_i
20 | when :boolean
21 | !false?(value)
22 | when :serialized
23 | # deserialization happens in the model
24 | value
25 | end
26 | end
27 |
28 | def write(type, value)
29 | return nil if value.nil?
30 |
31 | case type
32 | when :datetime
33 | if value.is_a?(String)
34 | value
35 | else
36 | value.in_time_zone("UTC").to_s
37 | end
38 | when :serialized
39 | # write the object directly.
40 | value
41 | when :boolean
42 | false?(value) ? "0" : "1"
43 | else
44 | value.to_s
45 | end
46 | end
47 |
48 | def deserialize(value)
49 | return nil if value.nil? || value == "null"
50 | JSON.parse(value)
51 | end
52 |
53 | private
54 |
55 | def false?(value)
56 | FALSE_VALUES.include?(value.to_s.downcase)
57 | end
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/property_sets/delegator.rb:
--------------------------------------------------------------------------------
1 | module PropertySets
2 | module Delegator
3 | # methods for moving what was once a literal column on
4 | # to a property_set table.
5 | #
6 | # delegates read, write and query methods to the property record or the property default
7 | #
8 | # Examples
9 | #
10 | # # Migrate :is_open to the :settings property set, and rename it :open,
11 | # # and migrate :same to property set :same
12 | # include PropertySets::Delegator
13 | # delegate_to_property_set :settings, :is_open => :open, :same => :same
14 | #
15 | def self.included(base)
16 | base.extend(ClassMethods)
17 | end
18 |
19 | module ClassMethods
20 | def delegate_to_property_set(setname, mappings)
21 | raise "Second argument must be a Hash" unless mappings.is_a?(Hash)
22 |
23 | unless respond_to?(:delegated_property_set_attributes)
24 | class_attribute :delegated_property_set_attributes
25 | end
26 | self.delegated_property_set_attributes ||= []
27 |
28 | mappings.each do |old_attr, new_attr|
29 | self.delegated_property_set_attributes << old_attr.to_s
30 | attribute old_attr, ActiveModel::Type::Value.new
31 | define_method(old_attr) {
32 | association = send(setname)
33 | type = association.association_class.type(new_attr)
34 | association.lookup_value(type, new_attr)
35 | }
36 | alias_method :"#{old_attr}_before_type_cast", old_attr
37 | define_method(:"#{old_attr}?") { send(setname).send(:"#{new_attr}?") }
38 | define_method(:"#{old_attr}=") do |value|
39 | if send(old_attr) != value
40 | send(:"#{old_attr}_will_change!")
41 | end
42 | send(setname).send(:"#{new_attr}=", value)
43 | super(value)
44 | end
45 |
46 | define_method(:"#{old_attr}_will_change!") do
47 | attribute_will_change!(old_attr)
48 | end
49 |
50 | define_method(:"#{old_attr}_changed?") do
51 | collection_proxy = send(setname)
52 | return false unless collection_proxy.loaded?
53 | setting = collection_proxy.lookup_without_default(new_attr)
54 |
55 | if !setting
56 | false # Nothing has been set which means that the attribute hasn't changed
57 | elsif setting.new_record?
58 | collection_proxy.association_class.default(new_attr) != setting.value
59 | else
60 | setting.value_changed?
61 | end
62 | end
63 | end
64 |
65 | # These are not database columns and should not be included in queries but
66 | # using the attributes API is the only way to track changes in the main model
67 | if respond_to?(:user_provided_columns)
68 | user_provided_columns.reject! { |k, _| delegated_property_set_attributes.include?(k.to_s) }
69 | end
70 | end
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/lib/property_sets/property_set_model.rb:
--------------------------------------------------------------------------------
1 | require "active_support"
2 |
3 | module PropertySets
4 | module PropertySetModel
5 | # https://dev.mysql.com/doc/refman/5.6/en/storage-requirements.html
6 | COLUMN_TYPE_LIMITS = {
7 | "tinyblob" => 255, # 2^8 - 1
8 | "tinytext" => 255,
9 | "blob" => 65535, # 2^16 - 1
10 | "text" => 65535,
11 | "mediumblob" => 16777215, # 2^24 - 1
12 | "mediumtext" => 16777215,
13 | "longblob" => 4294967295, # 2^32 - 1
14 | "longtext" => 4294967295
15 | }.freeze
16 |
17 | module InstanceMethods
18 | def false?
19 | ["false", "0", "", "off", "n"].member?(value.to_s.downcase)
20 | end
21 |
22 | def true?
23 | !false?
24 | end
25 |
26 | def enable
27 | update_attribute(:value, "1")
28 | self
29 | end
30 |
31 | def disable
32 | update_attribute(:value, "0")
33 | self
34 | end
35 |
36 | def protected?
37 | self.class.protected?(name.to_sym)
38 | end
39 |
40 | def value
41 | if value_serialized
42 | v = read_attribute(:value)
43 | @deserialized_value ||= PropertySets::Casting.deserialize(v)
44 | else
45 | super
46 | end
47 | end
48 |
49 | def value=(v)
50 | if value_serialized
51 | @deserialized_value = v
52 | write_attribute(:value, v.to_json)
53 | else
54 | super
55 | end
56 | end
57 |
58 | def reload(*args, &block)
59 | @deserialized_value = nil
60 | super
61 | end
62 |
63 | def to_s
64 | value.to_s
65 | end
66 |
67 | attr_accessor :value_serialized
68 |
69 | private
70 |
71 | def validate_format_of_name
72 | if name.blank?
73 | errors.add(:name, :blank)
74 | elsif !name.is_a?(String) || name !~ /^([a-z0-9]+_?)+$/
75 | errors.add(:name, :invalid)
76 | end
77 | end
78 |
79 | def validate_length_of_serialized_data
80 | if value_serialized && read_attribute(:value).to_s.size > value_column_limit
81 | errors.add(:value, :invalid)
82 | end
83 | end
84 |
85 | def coerce_value
86 | if value && !value_serialized
87 | self.value = value.to_s
88 | end
89 | end
90 |
91 | def owner_class_instance
92 | send(self.class.owner_class_sym)
93 | end
94 |
95 | def value_column_limit
96 | column = self.class.columns_hash.fetch("value")
97 |
98 | # use sql_type because type returns :text for all text types regardless of length
99 | column.limit || COLUMN_TYPE_LIMITS.fetch(column.sql_type)
100 | end
101 | end
102 |
103 | module ClassMethods
104 | def self.extended(base)
105 | base.validate :validate_format_of_name
106 | base.validate :validate_length_of_serialized_data
107 | base.before_create :coerce_value
108 | base.attr_accessible :name, :value if defined?(ProtectedAttributes)
109 | end
110 |
111 | def properties
112 | @properties ||= HashWithIndifferentAccess.new
113 | end
114 |
115 | def property(key, options = nil)
116 | properties[key] = options
117 | end
118 |
119 | def keys
120 | properties.keys
121 | end
122 |
123 | def default(key)
124 | PropertySets::Casting.read(type(key), raw_default(key))
125 | end
126 |
127 | def raw_default(key)
128 | properties[key].try(:[], :default)
129 | end
130 |
131 | def type(key)
132 | properties[key].try(:[], :type) || :string
133 | end
134 |
135 | def protected?(key)
136 | properties[key].try(:[], :protected) || false
137 | end
138 |
139 | def owner_class=(owner_class_name)
140 | @owner_class_sym = owner_class_name.to_s.demodulize.underscore.to_sym
141 |
142 | belongs_to owner_class_sym, class_name: owner_class_name
143 | validates_presence_of owner_class_sym, class_name: owner_class_name
144 | validates_uniqueness_of :name, scope: owner_class_key_sym, case_sensitive: false
145 | attr_accessible owner_class_key_sym, owner_class_sym if defined?(ProtectedAttributes)
146 | end
147 |
148 | def owner_assoc=(association)
149 | @owner_assoc = association
150 | end
151 |
152 | def owner_assoc
153 | @owner_assoc
154 | end
155 |
156 | def owner_class_sym
157 | @owner_class_sym
158 | end
159 |
160 | def owner_class_key_sym
161 | :"#{owner_class_sym}_id"
162 | end
163 | end
164 | end
165 | end
166 |
--------------------------------------------------------------------------------
/lib/property_sets/version.rb:
--------------------------------------------------------------------------------
1 | module PropertySets
2 | VERSION = "3.13.0"
3 | end
4 |
--------------------------------------------------------------------------------
/property_sets.gemspec:
--------------------------------------------------------------------------------
1 | require "./lib/property_sets/version"
2 |
3 | Gem::Specification.new "property_sets", PropertySets::VERSION do |s|
4 | s.summary = "Property sets for ActiveRecord."
5 | s.description = "This gem is an ActiveRecord extension which provides a convenient interface for managing per row properties."
6 | s.authors = ["Morten Primdahl"]
7 | s.email = "primdahl@me.com"
8 | s.homepage = "http://github.com/zendesk/property_sets"
9 | s.license = "Apache License Version 2.0"
10 |
11 | s.required_ruby_version = ">= 3.1"
12 |
13 | s.add_runtime_dependency("activerecord", ">= 6.1")
14 | s.add_runtime_dependency("json")
15 |
16 | s.add_development_dependency("bump")
17 | s.add_development_dependency("rake")
18 | s.add_development_dependency("actionpack")
19 | s.add_development_dependency("rspec")
20 | s.add_development_dependency("standard")
21 | s.add_development_dependency("byebug")
22 |
23 | s.files = `git ls-files lib`.split("\n")
24 | s.license = "MIT"
25 | end
26 |
--------------------------------------------------------------------------------
/spec/casting_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 | require "property_sets/casting"
3 |
4 | describe PropertySets::Casting do
5 | describe "#read" do
6 | it "return nil when given value nil regardless of type" do
7 | expect(PropertySets::Casting.read(:string, nil)).to be_nil
8 | expect(PropertySets::Casting.read(:hello, nil)).to be_nil
9 | end
10 |
11 | it "leave serialized data alone" do
12 | expect(PropertySets::Casting.read(:serialized, [1, 2, 3])).to eq([1, 2, 3])
13 | end
14 |
15 | it "reads boolean" do
16 | expect(PropertySets::Casting.read(:boolean, "true")).to be true
17 | expect(PropertySets::Casting.read(:boolean, "1")).to be true
18 | expect(PropertySets::Casting.read(:boolean, "something")).to be true
19 | expect(PropertySets::Casting.read(:boolean, "on")).to be true
20 | expect(PropertySets::Casting.read(:boolean, true)).to be true
21 | expect(PropertySets::Casting.read(:boolean, 1111)).to be true
22 | end
23 | end
24 |
25 | describe "#write" do
26 | it "return nil when given value nil regardless of type" do
27 | expect(PropertySets::Casting.write(:string, nil)).to be_nil
28 | expect(PropertySets::Casting.write(:hello, nil)).to be_nil
29 | end
30 |
31 | it "convert time instances to UTC" do
32 | time = Time.now.in_time_zone("CET")
33 | expect(PropertySets::Casting.write(:datetime, time)).to match(/UTC$/)
34 | end
35 |
36 | it "convert integers to strings" do
37 | expect(PropertySets::Casting.write(:integer, 123)).to eq("123")
38 | end
39 |
40 | it "convert random things to booleans" do
41 | expect(PropertySets::Casting.write(:boolean, 1)).to eq("1")
42 | expect(PropertySets::Casting.write(:boolean, true)).to eq("1")
43 | expect(PropertySets::Casting.write(:boolean, "dfsdff")).to eq("1")
44 |
45 | expect(PropertySets::Casting.write(:boolean, "")).to eq("0")
46 | expect(PropertySets::Casting.write(:boolean, nil)).to be_nil
47 | expect(PropertySets::Casting.write(:boolean, false)).to eq("0")
48 | expect(PropertySets::Casting.write(:boolean, 0)).to eq("0")
49 | expect(PropertySets::Casting.write(:boolean, "off")).to eq("0")
50 | expect(PropertySets::Casting.write(:boolean, "n")).to eq("0")
51 | end
52 |
53 | it "leave serialized data alone for the record to deal with" do
54 | a = [123]
55 | expect(PropertySets::Casting.write(:serialized, a)).to eq(a)
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/spec/delegator_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | describe PropertySets::Delegator do
4 | let(:account) { Parent::Account.create(name: "Name") }
5 | let(:default) { "skep" }
6 |
7 | describe "read" do
8 | it "not add a property" do
9 | account.old
10 | expect(account.settings.size).to eq(0)
11 | end
12 |
13 | it "delegate read to default" do
14 | expect(account.old).to eq(default)
15 | end
16 |
17 | it "delegate read to property value" do
18 | account.settings.hep = "new"
19 | expect(account.old).to eq("new")
20 | end
21 | end
22 |
23 | describe "write" do
24 | it "add a property" do
25 | account.old = "new"
26 | expect(account.settings.size).to eq(1)
27 | end
28 |
29 | it "delegate write" do
30 | account.old = "new"
31 | expect(account.settings.hep).to eq("new")
32 | expect(account.old).to eq("new")
33 | end
34 | end
35 |
36 | describe "changed?" do
37 | it "does not add a property" do
38 | account.old_changed?
39 | expect(account.settings.size).to eq(0)
40 | end
41 |
42 | it "is not changed when unchanged" do
43 | expect(account.old_changed?).to be false
44 | end
45 |
46 | it "is changed with new value" do
47 | account.old = "new"
48 | expect(account.old_changed?).to be true
49 | end
50 |
51 | it "is changed with new falsy value" do
52 | account.old = false
53 | expect(account.old_changed?).to be true
54 | end
55 |
56 | it "is changed with new nil value" do
57 | account.old = nil
58 | expect(account.old_changed?).to be true
59 | end
60 |
61 | it "is not changed with default value" do
62 | account.old = default
63 | expect(account.old_changed?).to be false
64 | end
65 |
66 | it "does not perform queries when association was never loaded and could not possibly be changed" do
67 | account
68 | assert_sql_queries 0 do
69 | expect(account.old_changed?).to be false
70 | end
71 | end
72 | end
73 |
74 | describe "before_type_case" do
75 | it "not add a property" do
76 | account.old_before_type_cast
77 | expect(account.settings.size).to eq(0)
78 | end
79 |
80 | it "return default" do
81 | expect(account.old_before_type_cast).to eq(default)
82 | end
83 |
84 | it "return setting" do
85 | account.old = "new"
86 | expect(account.old_before_type_cast).to eq("new")
87 | end
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/spec/inheritance_spec.rb:
--------------------------------------------------------------------------------
1 | require "active_support"
2 | require "active_record"
3 | require "property_sets"
4 |
5 | yaml_config = "spec/support/database.yml"
6 | ActiveRecord::Base.configurations = begin
7 | YAML.safe_load(IO.read(yaml_config), aliases: true)
8 | rescue ArgumentError
9 | YAML.safe_load(IO.read(yaml_config))
10 | end
11 |
12 | class AbstractUnshardedModel < ActiveRecord::Base
13 | self.abstract_class = true
14 |
15 | connects_to database: {writing: :unsharded_database, reading: :unsharded_database_replica}
16 | end
17 |
18 | class Vehicle < AbstractUnshardedModel
19 | property_set :settings do
20 | property :type
21 | end
22 | end
23 |
24 | describe PropertySets do
25 | it "creates property_set model" do
26 | expect(defined?(VehicleSetting)).to be_truthy
27 | end
28 |
29 | it "inherits from a correct class" do
30 | if ActiveRecord.gem_version >= Gem::Version.new("6.1")
31 | expect(VehicleSetting.superclass).to be(AbstractUnshardedModel)
32 | else
33 | expect(VehicleSetting.superclass).to be(ActiveRecord::Base)
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/spec/property_sets_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | old, $-w = $-w, nil
4 | # sqlite type differences:
5 | PropertySets::PropertySetModel::COLUMN_TYPE_LIMITS =
6 | PropertySets::PropertySetModel::COLUMN_TYPE_LIMITS.merge("varchar" => 65535)
7 | $-w = old
8 |
9 | describe PropertySets do
10 | let(:account) { Parent::Account.create(name: "Name") }
11 | let(:relation) { Parent::Account.reflections["settings"] }
12 |
13 | it "construct the container class" do
14 | expect(defined?(Parent::AccountSetting)).to be_truthy
15 | expect(defined?(Parent::AccountText)).to be_truthy
16 | expect(defined?(Parent::AccountTypedDatum)).to be_truthy
17 | end
18 |
19 | it "register the property sets used on a class" do
20 | %i[settings texts validations typed_data].each do |name|
21 | expect(Parent::Account.property_set_index).to include(name)
22 | end
23 | end
24 |
25 | it "sets inverse_of" do
26 | expect(relation.inverse_of.klass).to eq Parent::Account
27 | end
28 |
29 | it "reopening property_set is idempotent, first one wins on options etc" do
30 | expect(Array(relation.options[:extend])).to include Parent::Account::Woot
31 | expect(account.settings.extensions).to include Parent::Account::Woot
32 | end
33 |
34 | it "allow the owner class to be customized" do
35 | (Flux = Class.new(ActiveRecord::Base)).property_set(:blot, {
36 | owner_class_name: "Foobar"
37 | }) { property :test }
38 |
39 | expect(defined?(FoobarBlot)).to be_truthy
40 | end
41 |
42 | it "pass-through any options from the second parameter" do
43 | class AnotherThing < MainDatabase # standard:disable Lint/ConstantDefinitionInBlock:
44 | self.table_name = "things" # cheat and reuse things table
45 | end
46 |
47 | AnotherThing.property_set(:settings, extend: Parent::Account::Woot,
48 | table_name: "thing_settings")
49 |
50 | expect(AnotherThing.new.settings.extensions).to include(::Parent::Account::Woot)
51 | end
52 | end
53 |
54 | RSpec.shared_examples "different account models" do |account_klass|
55 | let(:account) { account_klass.create(name: "Name") }
56 | let(:relation) { account_klass.reflections["settings"] }
57 |
58 | it "support protecting attributes" do
59 | expect(account.settings.protected?(:pro)).to be true
60 | expect(account.settings.protected?(:foo)).to be false
61 | end
62 |
63 | it "allow enabling/disabling a property" do
64 | expect(account.settings.hep?).to be true
65 | account.settings.disable(:hep)
66 | expect(account.settings.hep?).to be false
67 | account.settings.enable(:hep)
68 | expect(account.settings.hep?).to be true
69 |
70 | account = Parent::Account.new
71 | expect(account.settings.foo?).to be false
72 | account.settings.enable(:foo)
73 | expect(account.settings.foo?).to be true
74 | end
75 |
76 | it "be empty on a new account" do
77 | expect(account.settings).to be_empty
78 | expect(account.texts).to be_empty
79 |
80 | expect(account.texts.foo?).to be false
81 | expect(account.texts.bar?).to be false
82 |
83 | expect(account.texts.foo).to be_nil
84 | expect(account.texts.bar).to be_nil
85 | end
86 |
87 | it "respond with defaults" do
88 | expect(account.settings.bar?).to be false
89 | expect(account.settings.bar).to be_nil
90 | expect(account.settings.hep?).to be true
91 | expect(account.settings.hep).to eq("skep")
92 | expect(account.settings.bool_nil).to be_nil
93 | expect(account.settings.bool_nil2).to be_nil
94 | expect(account.settings.bool_false).to be false
95 | expect(account.settings.bool_true).to be true
96 | end
97 |
98 | it "be flexible when fetching property data" do
99 | expect(account.settings.association_class.default(:hep)).to eq("skep")
100 | expect(account.settings.association_class.default("hep")).to eq("skep")
101 | end
102 |
103 | describe "querying for a setting that does not exist" do
104 | before do
105 | expect(account.settings).to eq([])
106 | expect(account.settings.hep?).to be true
107 | end
108 |
109 | it "not add a new setting" do
110 | expect(account.settings).to eq([])
111 | end
112 |
113 | it "give back the default value" do
114 | expect(account.settings.hep).to eq("skep")
115 | end
116 | end
117 |
118 | it "reject settings with an invalid name" do
119 | # Because we are running these specs with two separate classes
120 | # (Parent::Account & Parent::AccountAltDb), we need to build the
121 | # settings class class name manually.
122 | settings_klass = Object.const_get("#{account_klass}Setting") # standard:disable Performance/StringIdentifierArgument
123 | s = settings_klass.new(account.model_name.element.to_sym => account)
124 |
125 | valids = %w[hello hel_lo hell0] + [:hello]
126 | invalids = %w[_hello]
127 |
128 | valids.each do |valid|
129 | s.name = valid
130 | expect(s).to be_valid, "#{valid} is invalid: #{s.errors.inspect}"
131 | end
132 |
133 | invalids.each do |invalid|
134 | s.name = invalid
135 | expect(s).to_not be_valid, "#{invalid} is valid"
136 | end
137 | end
138 |
139 | it "validate uniqueness of settings" do
140 | account.settings.create!(name: "unique")
141 | expect {
142 | account.settings.create!(name: "unique")
143 | }.to raise_error(ActiveRecord::RecordInvalid, /Name has already been taken/)
144 | end
145 |
146 | it "be creatable using the = operator" do
147 | expect(account.settings.foo?).to be false
148 | ["1", "2"].each do |value|
149 | expect(account.settings.foo = value).to be_truthy
150 | expect(account.settings.foo?).to be true
151 | expect(account.settings.size).to eq(1)
152 | end
153 |
154 | expect(account.texts).to be_empty
155 | end
156 |
157 | it "coerce everything but nil to string" do
158 | account.settings.foo = 3
159 | account.save
160 | expect(account.settings.foo).to eq("3")
161 | account.settings.foo = nil
162 | account.save
163 | expect(account.settings.foo).to be_nil
164 | end
165 |
166 | it "reference the owner instance when constructing a new record" do
167 | record = account.settings.lookup(:baz)
168 | expect(record).to be_new_record
169 | # Because we are running these specs with two separate classes
170 | # (Parent::Account & Parent::AccountAltDb), we need to build the
171 | # method name manually (:account vs :account_alt_db).
172 | expect(record.send(account.model_name.element.to_sym).id).to eq(account.id)
173 | end
174 |
175 | it "reference the owner instance when constructing a new record ...on a new record" do
176 | account = Parent::Account.new(name: "New")
177 | record = account.settings.lookup(:baz)
178 |
179 | expect(record).to be_new_record
180 | expect(record.account).to eq(account)
181 | end
182 |
183 | describe "validations" do
184 | it "add an error when violated" do
185 | account.validations.validated = "hello"
186 | expect(account).to_not be_valid
187 | expect(account.errors.full_messages.first).to match(/BEEP$/)
188 | end
189 | end
190 |
191 | describe "#get" do
192 | before { account.settings.set(baz: "456") }
193 |
194 | it "fetch property pairs with string arguments" do
195 | expect(account.settings.lookup_without_default(:baz)).to be_truthy
196 | expect(account.settings.get(["baz"])).to eq("baz" => "456")
197 | end
198 |
199 | it "fetch property pairs with symbol arguments" do
200 | expect(account.settings.get([:baz])).to eq("baz" => "456")
201 | end
202 |
203 | it "return all property pairs if no arguments are provided" do
204 | expect(account.settings.get.keys.sort).to eq(
205 | %w[bar baz bool_false bool_nil bool_nil2 bool_true foo hep pro].sort
206 | )
207 | end
208 |
209 | it "ignore non-existent keys" do
210 | expect(account.settings.get([:baz, :red])).to eq("baz" => "456")
211 | end
212 |
213 | it "include default property pairs" do
214 | expect(account.settings.lookup_without_default(:hep)).to be_nil
215 | expect(account.settings.get(["hep"])).to eq("hep" => "skep")
216 | end
217 |
218 | it "return a hash with values that can be fetched by string or symbol" do
219 | expect(account.settings.get(["baz"]).fetch(:baz)).to eq("456")
220 | end
221 |
222 | it "return serialized values" do
223 | account.typed_data.set(serialized_prop: [1, 2])
224 | expect(account.typed_data.lookup_without_default(:serialized_prop)).to be_truthy
225 | expect(account.typed_data.get([:serialized_prop])).to eq("serialized_prop" => [1, 2])
226 | end
227 | end
228 |
229 | describe "#set" do
230 | it "support writing multiple values to the association" do
231 | expect(account.settings.foo?).to be_falsy
232 | expect(account.settings.bar?).to be_falsy
233 |
234 | account.settings.set(foo: "123", bar: "456")
235 |
236 | expect(account.settings.foo?).to be_truthy
237 | expect(account.settings.bar?).to be_truthy
238 | end
239 |
240 | it "convert string keys to symbols to ensure consistent lookup" do
241 | account.settings.set(foo: "123")
242 | account.settings.set("foo" => "456")
243 | expect(account.save!).to be true
244 | end
245 |
246 | it "work identically for new and existing owner objects" do
247 | [account, Parent::Account.new(name: "Mibble")].each do |account|
248 | account.settings.set(foo: "123", bar: "456")
249 |
250 | expect(account.settings.size).to eq(2)
251 | expect(account.settings.foo).to eq("123")
252 | expect(account.settings.bar).to eq("456")
253 |
254 | account.settings.set(bar: "789", baz: "012")
255 |
256 | expect(account.settings.size).to eq(3)
257 | expect(account.settings.foo).to eq("123")
258 | expect(account.settings.bar).to eq("789")
259 | expect(account.settings.baz).to eq("012")
260 | end
261 | end
262 |
263 | it "be updateable as AR nested attributes" do
264 | expect(
265 | account.texts_attributes = [{name: "foo", value: "1"}, {name: "bar", value: "0"}]
266 | ).to be_truthy
267 |
268 | account.save!
269 |
270 | expect(account.texts.foo).to eq("1")
271 | expect(account.texts.bar).to eq("0")
272 |
273 | account.update_attributes!(texts_attributes: [
274 | {id: account.texts.lookup(:foo).id, name: "foo", value: "0"},
275 | {id: account.texts.lookup(:bar).id, name: "bar", value: "1"}
276 | ])
277 |
278 | expect(account.texts.foo).to eq("0")
279 | expect(account.texts.bar).to eq("1")
280 | end
281 |
282 | it "be updateable as a nested structure" do
283 | account.settings.baz = "1"
284 | account.save!
285 |
286 | expect(account.settings.foo?).to be false
287 | expect(account.settings.bar?).to be false
288 | expect(account.settings.baz?).to be true
289 | expect(account.settings.pro?).to be false
290 |
291 | account.update_attributes!(
292 | name: "Kim",
293 | settings: {foo: "1", baz: "0", pro: "1"}
294 | )
295 |
296 | account.reload
297 |
298 | # set
299 | expect(account.settings.foo?).to be true
300 | expect(account.settings.foo).to eq("1")
301 |
302 | # kept
303 | expect(account.settings.bar?).to be false
304 | expect(account.settings.bar).to be_nil
305 |
306 | # unset
307 | expect(account.settings.baz?).to be false
308 | expect(account.settings.baz).to eq("0")
309 |
310 | # protected -> not set
311 | expect(account.settings.pro?).to be false
312 | expect(account.settings.pro).to be_nil
313 | end
314 | end
315 |
316 | describe "lookup" do
317 | describe "with data" do
318 | it "return the data" do
319 | account.texts.foo = "1"
320 | expect(account.texts.lookup(:foo).value).to eq("1")
321 | end
322 |
323 | it "returns false" do
324 | account.settings.bool_nil = false
325 | expect(account.settings.lookup(:bool_nil).value).to eq("0")
326 | end
327 | end
328 |
329 | describe "without data" do
330 | it "returns nil without default" do
331 | expect(account.texts.lookup(:foo).value).to be_nil
332 | end
333 |
334 | it "create a new record" do
335 | expect(account.texts.detect { |p| p.name == "foo" }).to be_falsy
336 | account.texts.lookup(:foo).value
337 | expect(account.texts.detect { |p| p.name == "foo" }).to be_truthy
338 | end
339 |
340 | it "returns nil with default" do
341 | expect(account.texts.lookup(:hep).value).to be_nil
342 | end
343 |
344 | it "returns nil with default for booleans" do
345 | expect(account.texts.lookup(:bool_false).value).to be_nil
346 | end
347 | end
348 | end
349 |
350 | describe "lookup_without_default" do
351 | it "return the row if it exists" do
352 | account.texts.foo = "1"
353 | expect(account.texts.lookup_without_default(:foo).value).to eq("1")
354 | end
355 |
356 | it "return nil otherwise" do
357 | expect(account.texts.lookup_without_default(:foo)).to be_nil
358 | end
359 | end
360 |
361 | describe "save" do
362 | it "call save on all dem records" do
363 | account.settings.foo = "1"
364 | account.settings.bar = "2"
365 | account.settings.save
366 |
367 | account.reload
368 | expect(account.settings.foo).to eq("1")
369 | expect(account.settings.bar).to eq("2")
370 | end
371 |
372 | it "sets forwarded attributes" do
373 | other_account = Other::Account.new(name: "creating", old: "forwarded value")
374 | other_account.save
375 | expect(other_account.old).to eq("forwarded value")
376 | end
377 | end
378 |
379 | describe "update_attribute for forwarded method" do
380 | it "creates changed attributes" do
381 | account.update_attribute(:old, "it works!")
382 | expect(account.previous_changes["old"].last).to eq("it works!")
383 | expect(account_klass.find(account.id).old).to eq("it works!")
384 | end
385 |
386 | it "updates changed attributes for existing property_set data" do
387 | account.settings.hep = "saved previously"
388 | account.save
389 | account.update_attribute(:old, "it works!")
390 | expect(account.previous_changes["old"].last).to eq("it works!")
391 | expect(account_klass.find(account.id).old).to eq("it works!")
392 | end
393 |
394 | it "updates changed attributes for existing property_set data after set through forwarded method" do
395 | account.old = "saved previously"
396 | account.save
397 | account.update_attribute(:old, "it works!")
398 | expect(account.previous_changes["old"].last).to eq("it works!")
399 | expect(account_klass.find(account.id).old).to eq("it works!")
400 | end
401 | end
402 |
403 | describe "assign_attributes for forwarded method" do
404 | it "sets the attribute value" do
405 | account.assign_attributes(old: "assigned!")
406 | expect(account.old).to eq("assigned!")
407 | end
408 |
409 | it "sets the object's changed attributes" do
410 | account.assign_attributes(old: "assigned!")
411 | expect(account).to be_changed
412 | expect(account.changed_attributes).to include(:old)
413 | end
414 | end
415 |
416 | describe "update_columns for forwarded method" do
417 | it "does not write to a missing column" do
418 | account.update_columns(name: "test", old: "it works!")
419 | expect(account.previous_changes).to_not include("old")
420 | end
421 |
422 | it "does not prevent other non-delegated property set models from updating" do
423 | thing = Thing.create(name: "test")
424 | expect(thing.update_columns(name: "it works")).to be
425 | end
426 | end
427 |
428 | describe "typed columns" do
429 | it "typecast the default value" do
430 | expect(account.typed_data.association_class.default(:default_prop)).to eq(123)
431 | end
432 |
433 | describe "string data" do
434 | it "be writable and readable" do
435 | account.typed_data.string_prop = "foo"
436 | expect(account.typed_data.string_prop).to eq("foo")
437 | end
438 | end
439 |
440 | describe "floating point data" do
441 | it "be writable and readable" do
442 | account.typed_data.float_prop = 1.97898
443 | expect(account.typed_data.float_prop).to eq(1.97898)
444 | account.save!
445 | expect(account.typed_data.float_prop).to eq(1.97898)
446 | end
447 | end
448 |
449 | describe "integer data" do
450 | it "be writable and readable" do
451 | account.typed_data.int_prop = 25
452 | expect(account.typed_data.int_prop).to eq(25)
453 | account.save!
454 | expect(account.typed_data.int_prop).to eq(25)
455 |
456 | expect(account.typed_data.lookup("int_prop").value).to eq("25")
457 | end
458 | end
459 |
460 | describe "datetime data" do
461 | it "be writable and readable" do
462 | ts = Time.at(Time.now.to_i)
463 | account.typed_data.datetime_prop = ts
464 |
465 | expect(account.typed_data.datetime_prop).to eq(ts)
466 | account.save!
467 | expect(account.typed_data.datetime_prop).to eq(ts)
468 | end
469 |
470 | it "store data in UTC" do
471 | ts = Time.at(Time.now.to_i)
472 | string_rep = ts.in_time_zone("UTC").to_s
473 | account.typed_data.datetime_prop = ts
474 | account.save!
475 | expect(account.typed_data.lookup("datetime_prop").value).to eq(string_rep)
476 | end
477 | end
478 |
479 | describe "serialized data" do
480 | it "store data in json" do
481 | value = {a: 1, b: 2}
482 | account.typed_data.serialized_prop = value
483 | account.save!
484 | account.reload
485 | expect(account.typed_data.serialized_prop).to eq("a" => 1, "b" => 2)
486 | end
487 |
488 | it "retrieve default values from JSON" do
489 | expect(account.typed_data.serialized_prop_with_default).to eq([])
490 | end
491 |
492 | it "not overflow the column" do
493 | account.typed_data.serialized_prop = (1..100_000).to_a
494 | expect(account.typed_data.lookup(:serialized_prop)).to_not be_valid
495 | expect(account.save).to be false
496 | end
497 |
498 | it "not overflow on other text types" do
499 | account.tiny_texts.serialized = (1..2**10).to_a # column size is 2^8 - 1
500 | expect(account.tiny_texts.lookup(:serialized)).to_not be_valid
501 | expect(account.save).to be false
502 | end
503 |
504 | it "allow for destructive operators" do
505 | value = {a: 1, b: 2}
506 | account.typed_data.serialized_prop = value
507 | account.typed_data.serialized_prop[:c] = 3
508 | expect(account.typed_data.serialized_prop[:c]).to eq(3)
509 | end
510 |
511 | it "deal with nil values properly going in" do
512 | account.typed_data.serialized_prop = nil
513 | expect {
514 | account.save!
515 | }.to_not raise_error
516 | end
517 |
518 | it "deal with nil values properly coming out" do
519 | expect(account.typed_data.serialized_prop).to be_nil
520 | end
521 | end
522 | end
523 | end
524 |
525 | describe Parent::Account do
526 | it_behaves_like "different account models", Parent::Account
527 | end
528 |
529 | describe Parent::AccountAltDb do
530 | it_behaves_like "different account models", Parent::AccountAltDb
531 | end
532 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | require "bundler/setup"
2 | require "logger"
3 | require "active_support"
4 | require "active_record"
5 | require "active_record/fixtures"
6 |
7 | ENV["RAILS_ENV"] = "test"
8 |
9 | LEGACY_CONNECTION_HANDLING = (ENV["LEGACY_CONNECTION_HANDLING"] == "true")
10 |
11 | case "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
12 | when "7.0"
13 | ActiveRecord.legacy_connection_handling = LEGACY_CONNECTION_HANDLING
14 | when "6.1"
15 | ActiveRecord::Base.legacy_connection_handling = LEGACY_CONNECTION_HANDLING
16 | end
17 |
18 | require "property_sets"
19 | require "property_sets/delegator"
20 |
21 | require "support/database_config"
22 | require "support/models"
23 | require "support/database_migrations"
24 |
25 | I18n.enforce_available_locales = false
26 |
27 | # http://stackoverflow.com/questions/5490411/counting-the-number-of-queries-performed/43810063#43810063
28 | module QueryAssertions
29 | def sql_queries(&)
30 | queries = []
31 | counter = ->(*, payload) {
32 | queries << payload.fetch(:sql) unless ["CACHE", "SCHEMA"].include?(payload.fetch(:name))
33 | }
34 |
35 | ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &)
36 |
37 | queries
38 | end
39 |
40 | def assert_sql_queries(expected, &)
41 | queries = sql_queries(&)
42 | expect(queries.count).to eq(expected), "Expected #{expected} queries, but found #{queries.count}:\n#{queries.join("\n")}"
43 | end
44 | end
45 |
46 | module ActiveRecord
47 | module Associations
48 | class CollectionProxy
49 | def scoping
50 | raise "CollectionProxy delegates unknown methods to target (association_class) via method_missing, wrapping the call with `scoping`. Instead, call the method directly on the association_class!"
51 | end
52 | end
53 | end
54 | end
55 |
56 | RSpec.configure { |c| c.include QueryAssertions }
57 |
--------------------------------------------------------------------------------
/spec/support/acts_like_an_integer.rb:
--------------------------------------------------------------------------------
1 | class ActsLikeAnInteger
2 | def to_i
3 | 123
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/spec/support/database.yml:
--------------------------------------------------------------------------------
1 | test:
2 | unsharded_database:
3 | adapter: sqlite3
4 | encoding: utf8
5 | database: unsharded_database
6 |
7 | unsharded_database_replica:
8 | adapter: sqlite3
9 | encoding: utf8
10 | database: unsharded_database_replica
11 | replica: true
12 |
--------------------------------------------------------------------------------
/spec/support/database_config.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | config = {
4 | test: {
5 | test_database: {
6 | adapter: "sqlite3",
7 | database: ":memory:"
8 | },
9 | test_alt_database: {
10 | adapter: "sqlite3",
11 | database: ":memory:"
12 | }
13 | }
14 | }
15 |
16 | ActiveRecord::Base.configurations = config
17 |
--------------------------------------------------------------------------------
/spec/support/database_migrations.rb:
--------------------------------------------------------------------------------
1 | # setup database
2 | require "active_record"
3 | ActiveRecord::Base.logger = Logger.new($stdout)
4 | ActiveRecord::Base.logger.level = Logger::ERROR
5 |
6 | ActiveRecord::Base.establish_connection(:test_database)
7 | ActiveRecord::Base.connection.execute("select 1")
8 |
9 | ActiveRecord::Migration.verbose = false
10 |
11 | class CreatePrimaryTables < ActiveRecord::Migration[6.0]
12 | def connection
13 | MainDatabase.connection
14 | end
15 |
16 | def change
17 | create_table "account_benchmark_settings", force: true do |t|
18 | t.integer "account_id"
19 | t.string "name"
20 | t.string "value"
21 | t.datetime "created_at"
22 | t.datetime "updated_at"
23 | end
24 |
25 | add_index :account_benchmark_settings, [:account_id, :name], unique: true
26 |
27 | create_table "accounts", force: true do |t|
28 | t.string "name"
29 | t.datetime "created_at"
30 | t.datetime "updated_at"
31 | end
32 |
33 | create_table "account_alt_dbs", force: true do |t|
34 | t.string "name"
35 | t.datetime "created_at"
36 | t.datetime "updated_at"
37 | end
38 |
39 | create_table "account_settings", force: true do |t|
40 | t.integer "account_id"
41 | t.string "name"
42 | t.string "value"
43 | t.datetime "created_at"
44 | t.datetime "updated_at"
45 | end
46 |
47 | add_index :account_settings, [:account_id, :name], unique: true
48 |
49 | create_table "account_texts", force: true do |t|
50 | t.integer "account_id"
51 | t.string "name"
52 | t.string "value"
53 | t.datetime "created_at"
54 | t.datetime "updated_at"
55 | end
56 |
57 | add_index :account_texts, [:account_id, :name], unique: true
58 |
59 | create_table "account_typed_data", force: true do |t|
60 | t.integer "account_id"
61 | t.string "name"
62 | t.string "value"
63 | t.datetime "created_at"
64 | t.datetime "updated_at"
65 | end
66 |
67 | add_index :account_typed_data, [:account_id, :name], unique: true
68 |
69 | create_table "account_validations", force: true do |t|
70 | t.integer "account_id"
71 | t.string "name"
72 | t.string "value"
73 | t.datetime "created_at"
74 | t.datetime "updated_at"
75 | end
76 |
77 | add_index :account_validations, [:account_id, :name], unique: true
78 |
79 | create_table "account_tiny_texts", force: true do |t|
80 | t.integer "account_id"
81 | t.string "name"
82 | t.text "value", limit: (2**8 - 1)
83 | t.datetime "created_at"
84 | t.datetime "updated_at"
85 | end
86 |
87 | add_index :account_tiny_texts, [:account_id, :name], unique: true
88 |
89 | create_table "things", force: true do |t|
90 | t.string "name"
91 | t.datetime "created_at"
92 | t.datetime "updated_at"
93 | end
94 |
95 | create_table "thing_settings", force: true do |t|
96 | t.integer "thing_id"
97 | t.string "name"
98 | t.string "value"
99 | t.datetime "created_at"
100 | t.datetime "updated_at"
101 | end
102 |
103 | add_index :thing_settings, [:thing_id, :name], unique: true
104 | end
105 | end
106 |
107 | CreatePrimaryTables.migrate(:up)
108 |
109 | class CreateAccountAltDatabase < ActiveRecord::Migration[6.0]
110 | def connection
111 | AltDatabase.connection
112 | end
113 |
114 | def change
115 | create_table "account_alt_db_settings", force: true do |t|
116 | t.integer "account_alt_db_id"
117 | t.string "name"
118 | t.string "value"
119 | t.datetime "created_at"
120 | t.datetime "updated_at"
121 | end
122 |
123 | add_index :account_alt_db_settings, [:account_alt_db_id, :name], unique: true
124 |
125 | create_table "account_alt_db_texts", force: true do |t|
126 | t.integer "account_alt_db_id"
127 | t.string "name"
128 | t.string "value"
129 | t.datetime "created_at"
130 | t.datetime "updated_at"
131 | end
132 |
133 | add_index :account_alt_db_texts, [:account_alt_db_id, :name], unique: true
134 |
135 | create_table "account_alt_db_typed_data", force: true do |t|
136 | t.integer "account_alt_db_id"
137 | t.string "name"
138 | t.string "value"
139 | t.datetime "created_at"
140 | t.datetime "updated_at"
141 | end
142 |
143 | add_index :account_alt_db_typed_data, [:account_alt_db_id, :name], unique: true
144 |
145 | create_table "account_alt_db_validations", force: true do |t|
146 | t.integer "account_alt_db_id"
147 | t.string "name"
148 | t.string "value"
149 | t.datetime "created_at"
150 | t.datetime "updated_at"
151 | end
152 |
153 | add_index :account_alt_db_validations, [:account_alt_db_id, :name], unique: true
154 |
155 | create_table "account_alt_db_tiny_texts", force: true do |t|
156 | t.integer "account_alt_db_id"
157 | t.string "name"
158 | t.text "value", limit: (2**8 - 1)
159 | t.datetime "created_at"
160 | t.datetime "updated_at"
161 | end
162 |
163 | add_index :account_alt_db_tiny_texts, [:account_alt_db_id, :name], unique: true
164 | end
165 | end
166 |
167 | CreateAccountAltDatabase.migrate(:up)
168 |
--------------------------------------------------------------------------------
/spec/support/models.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "acts_like_an_integer"
4 |
5 | if LEGACY_CONNECTION_HANDLING
6 | class MainDatabase < ActiveRecord::Base
7 | self.abstract_class = true
8 | end
9 |
10 | class AltDatabase < ActiveRecord::Base
11 | self.abstract_class = true
12 | establish_connection(:test_alt_database)
13 | end
14 | else
15 | class MainDatabase < ActiveRecord::Base
16 | self.abstract_class = true
17 |
18 | connects_to(database: {writing: :test_database, reading: :test_database})
19 | end
20 |
21 | class AltDatabase < ActiveRecord::Base
22 | self.abstract_class = true
23 |
24 | connects_to(database: {writing: :test_alt_database, reading: :test_alt_database})
25 | end
26 | end
27 |
28 | module Parent
29 | class Account < MainDatabase
30 | include PropertySets::Delegator
31 |
32 | delegate_to_property_set :settings, old: :hep
33 |
34 | # nonsense module to use in options below, only used as a marker
35 | module Woot # doesn't actually seem to be used in AR4 ?
36 | end
37 |
38 | property_set :settings, extend: Woot do
39 | property :foo
40 | property :bar
41 | property :baz
42 | property :hep, default: "skep"
43 | property :pro, protected: true
44 | property :bool_true, type: :boolean, default: true
45 | property :bool_false, type: :boolean, default: false
46 | property :bool_nil, type: :boolean, default: nil
47 | end
48 |
49 | property_set :settings do
50 | # reopening should maintain `extend` above
51 | property :bool_nil2, type: :boolean
52 | end
53 |
54 | property_set :texts do
55 | property :foo
56 | property :bar
57 | end
58 |
59 | accepts_nested_attributes_for :texts
60 |
61 | property_set :validations do
62 | property :validated
63 | property :regular
64 |
65 | validates_format_of :value, with: /\d+/, message: "BEEP", if: lambda { |r| r.name.to_sym == :validated }
66 | end
67 |
68 | property_set :typed_data do
69 | property :string_prop, type: :string
70 | property :datetime_prop, type: :datetime
71 | property :float_prop, type: :float
72 | property :int_prop, type: :integer
73 | property :serialized_prop, type: :serialized
74 | property :default_prop, type: :integer, default: ActsLikeAnInteger.new
75 | property :serialized_prop_with_default, type: :serialized, default: "[]"
76 | end
77 |
78 | property_set :tiny_texts do
79 | property :serialized, type: :serialized
80 | end
81 | end
82 | end
83 |
84 | module Other
85 | class Account < ::Parent::Account
86 | end
87 | end
88 |
89 | module Parent
90 | class AccountAltDb < MainDatabase
91 | include PropertySets::Delegator
92 |
93 | self.property_sets_connection_class = AltDatabase
94 |
95 | delegate_to_property_set :settings, old: :hep
96 |
97 | # nonsense module to use in options below, only used as a marker
98 | module Woot # doesn't actually seem to be used in AR4 ?
99 | end
100 |
101 | property_set :settings, extend: Woot do
102 | property :foo
103 | property :bar
104 | property :baz
105 | property :hep, default: "skep"
106 | property :pro, protected: true
107 | property :bool_true, type: :boolean, default: true
108 | property :bool_false, type: :boolean, default: false
109 | property :bool_nil, type: :boolean, default: nil
110 | end
111 |
112 | property_set :settings do
113 | # reopening should maintain `extend` above
114 | property :bool_nil2, type: :boolean
115 | end
116 |
117 | property_set :texts do
118 | property :foo
119 | property :bar
120 | end
121 |
122 | accepts_nested_attributes_for :texts
123 |
124 | property_set :validations do
125 | property :validated
126 | property :regular
127 |
128 | validates_format_of :value, with: /\d+/, message: "BEEP", if: lambda { |r| r.name.to_sym == :validated }
129 | end
130 |
131 | property_set :typed_data do
132 | property :string_prop, type: :string
133 | property :datetime_prop, type: :datetime
134 | property :float_prop, type: :float
135 | property :int_prop, type: :integer
136 | property :serialized_prop, type: :serialized
137 | property :default_prop, type: :integer, default: ActsLikeAnInteger.new
138 | property :serialized_prop_with_default, type: :serialized, default: "[]"
139 | end
140 |
141 | property_set :tiny_texts do
142 | property :serialized, type: :serialized
143 | end
144 | end
145 | end
146 |
147 | # No delegated property_set
148 | class Thing < MainDatabase
149 | property_set :settings do
150 | property :foo
151 | end
152 | end
153 |
--------------------------------------------------------------------------------
/spec/view_extensions_spec.rb:
--------------------------------------------------------------------------------
1 | require "spec_helper"
2 |
3 | describe "property set view extensions" do
4 | def base_options
5 | {
6 | name: "#{object_name}[#{property_set}][#{property}]",
7 | id: "#{object_name}_#{property_set}_#{property}",
8 | object: object
9 | }
10 | end
11 |
12 | let(:property_set) { :settings }
13 | let(:property) { :active }
14 | let(:object_name) { "object_name" }
15 | let(:object) { double("View object") }
16 | let(:template) { double("Template") }
17 | let(:builder) { ActionView::Helpers::FormBuilder.new(object_name, object, template, {}) }
18 | let(:proxy) { builder.property_set(property_set) }
19 |
20 | it "provide a form builder proxy" do
21 | expect(proxy).to be_a(ActionView::Helpers::FormBuilder::PropertySetFormBuilderProxy)
22 | expect(proxy.property_set).to eq(property_set)
23 | end
24 |
25 | describe "object is not available" do
26 | let(:builder) { ActionView::Helpers::FormBuilder.new(object_name, nil, template, {}) }
27 |
28 | it "fetch the target object when not available" do
29 | allow(object).to receive(property_set).and_return(double("Fake property", property => "value"))
30 | allow(template).to receive(:hidden_field)
31 |
32 | expect(template).to receive(:instance_variable_get).with(:"@#{object_name}").and_return(object)
33 | proxy.hidden_field(property)
34 | end
35 | end
36 |
37 | describe "#check_box" do
38 | describe "when called with checked true for a truth value" do
39 | before do
40 | settings = double("Fake setting", property => "1", :"#{property}?" => true)
41 | allow(object).to receive(property_set).and_return(settings)
42 | end
43 |
44 | it "build a checkbox with the proper parameters" do
45 | expected_options = base_options.merge(checked: true)
46 | expect(template).to receive(:check_box).with(object_name, property, expected_options, "1", "0")
47 | proxy.check_box(property)
48 | end
49 | end
50 |
51 | describe "when called with checked false for a truth value" do
52 | before do
53 | settings = double("Fake setting", property => "0", :"#{property}?" => false)
54 | allow(object).to receive(property_set).and_return(settings)
55 | end
56 |
57 | it "build a checkbox with the proper parameters" do
58 | expected_options = base_options.merge(checked: false)
59 | expect(template).to receive(:check_box).with(object_name, property, expected_options, "1", "0")
60 | proxy.check_box(property)
61 | end
62 | end
63 | end
64 |
65 | describe "#hidden_field" do
66 | describe "when the persisted value is not a boolean" do
67 | before do
68 | settings = double("Fake property", property => "persisted value")
69 | allow(object).to receive(property_set).and_return(settings)
70 | end
71 |
72 | it "build a hidden field with the persisted value" do
73 | expected_options = base_options.merge(value: "persisted value")
74 | expect(template).to receive(:hidden_field).with(object_name, property, expected_options)
75 | proxy.hidden_field(property)
76 | end
77 |
78 | describe "and a value is provided" do
79 | it "build a hidden field with the provided value" do
80 | expected_options = base_options.merge(value: "provided value")
81 | expect(template).to receive(:hidden_field).with(object_name, property, expected_options)
82 | proxy.hidden_field(property, {value: "provided value"})
83 | end
84 | end
85 | end
86 |
87 | describe "when the persisted value is a boolean" do
88 | it "build a hidden field with cast boolean value if it is a boolean true" do
89 | settings = double("Fake property", property => true)
90 | allow(object).to receive(property_set).and_return(settings)
91 |
92 | expected_options = base_options.merge(value: "1")
93 | expect(template).to receive(:hidden_field).with(object_name, property, expected_options)
94 | proxy.hidden_field(property)
95 | end
96 |
97 | it "build a hidden field with cast boolean value if it is a boolean false" do
98 | settings = double("Fake property", property => false)
99 | allow(object).to receive(property_set).and_return(settings)
100 |
101 | expected_options = base_options.merge(value: "0")
102 | expect(template).to receive(:hidden_field).with(object_name, property, expected_options)
103 | proxy.hidden_field(property)
104 | end
105 | end
106 | end
107 |
108 | describe "#text_field" do
109 | describe "when called with a provided value" do
110 | before do
111 | settings = double("Fake property", property => "persisted value")
112 | allow(object).to receive(property_set).and_return(settings)
113 | end
114 |
115 | it "build a text field with the provided value" do
116 | expected_options = base_options.merge(value: "provided value")
117 | expect(template).to receive(:text_field).with(object_name, property, expected_options)
118 | proxy.text_field(property, {value: "provided value"})
119 | end
120 | end
121 | end
122 |
123 | describe "#radio_button" do
124 | let(:expected_options) {
125 | base_options.merge(
126 | id: "#{object_name}_#{property_set}_#{property}_hello",
127 | checked: false
128 | )
129 | }
130 |
131 | let(:faked_property) { double("Fake property", property => "hello") }
132 |
133 | before do
134 | allow(object).to receive(property_set).and_return(faked_property)
135 | end
136 |
137 | it "generate a unique id when one is not provided" do
138 | expected_options[:id] = "#{object_name}_#{property_set}_#{property}_pancake"
139 | expect(template).to receive(:radio_button).with(object_name, property, "pancake", expected_options)
140 | proxy.radio_button(property, "pancake")
141 | end
142 |
143 | describe "when called with checked true for a truth value" do
144 | it "call with checked true for a truth value" do
145 | expected_options[:checked] = true
146 | expect(template).to receive(:radio_button).with(object_name, property, "hello", expected_options)
147 | proxy.radio_button(property, "hello")
148 | end
149 | end
150 |
151 | describe "when called with a value of a different type" do
152 | let(:faked_property) { double("Fake property", property => "1") }
153 |
154 | it "call with checked false" do
155 | expected_options[:id] = "#{object_name}_#{property_set}_#{property}_1"
156 | expect(template).to receive(:radio_button).with(object_name, property, 1, expected_options)
157 | proxy.radio_button(property, 1)
158 | end
159 | end
160 | end
161 |
162 | describe "#select" do
163 | before do
164 | settings = double("Fake property", count: "2")
165 | allow(object).to receive(property_set).and_return(settings)
166 | end
167 |
168 | it "render a with s" do
169 | select_options = {selected: "2"}
170 | select_choices = [["One", 1], ["Two", 2], ["Three", 3]]
171 |
172 | expect(template).to receive(:select).with("object_name[settings]", :count, select_choices, select_options, {})
173 | proxy.select(:count, select_choices)
174 | end
175 |
176 | it "merge :html_options" do
177 | select_options = {selected: "2"}
178 | select_choices = [["One", 1], ["Two", 2], ["Three", 3]]
179 | html_options = {id: "foo", name: "bar", disabled: true}
180 |
181 | expect(template).to receive(:select).with("object_name[settings]", :count, select_choices, select_options, html_options)
182 | proxy.select(:count, select_choices, select_options, html_options)
183 | end
184 | end
185 | end
186 |
--------------------------------------------------------------------------------