acts_as_ditto 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +115 -0
- data/Rakefile +28 -0
- data/lib/acts_as_ditto.rb +16 -0
- data/lib/ditto/acts_as_ditto.rb +31 -0
- data/lib/ditto/configuration.rb +49 -0
- data/lib/ditto/duplicator.rb +99 -0
- data/lib/ditto/version.rb +5 -0
- data/sig/acts_as_ditto.rbs +4 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f4431085c0b3038f16f982d08995f253b6142fc757ff81b9577face8a3d7837d
|
|
4
|
+
data.tar.gz: 232a203d533035d92ec0d2f3ed76322e9c9296b94e18141b1988513852402591
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 55d514d95ab78c2e971075d71976460d4360d07aad3fe6e0bbf333bd59ff854017b7fedb82e930d581d1105cedcb7d0e76e3f0a481701dd217f785423f3ec041
|
|
7
|
+
data.tar.gz: '091c62145f730ca95efb5a2e20240126c2d2d69d288f995d8ab6a485149741070b393c2a55512997d85fd632b228c3c10abf1b4eb370d3b55bdbacac66f25bd1'
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"acts_as_ditto" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels:
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact me at ["jaeggi@puzzle.ch"](mailto:"jaeggi@puzzle.ch").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Niklas Jäggi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Ditto
|
|
2
|
+
|
|
3
|
+
Ditto adds an `acts_as_ditto` DSL to your ActiveRecord models for duplicating
|
|
4
|
+
records with custom control
|
|
5
|
+
|
|
6
|
+
Similar to `.dup`, but with the options like resetting attributes, applying static
|
|
7
|
+
overrides, running custom transformations, or recursively cloning associations.
|
|
8
|
+
|
|
9
|
+
And yes, the name comes from the Pokemon [Ditto](https://www.pokemon.com/us/pokedex/ditto), which can use its unique move transform to mirror any opposing Pokemon :D
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Install the gem and add it to the application's Gemfile by executing:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle add acts_as_ditto
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Call `acts_as_ditto` on a model, then duplicate records with `#ditto` or `#ditto!`:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
class Invoice < ApplicationRecord
|
|
25
|
+
acts_as_ditto do
|
|
26
|
+
override status: "draft"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Duplicating associatons
|
|
32
|
+
|
|
33
|
+
Recursively duplicates the given associations along with the record.
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
acts_as_ditto do
|
|
37
|
+
clone_associations :posts, :address
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The associated model doesn't need `acts_as_ditto` itself, if it does, its own custom
|
|
42
|
+
configuration is used, otherwise they're duplicated as is.
|
|
43
|
+
|
|
44
|
+
To duplicate multiple layers of associations, every model needs an `acts_as_ditto` configuration
|
|
45
|
+
that configures which associations get cloned.
|
|
46
|
+
|
|
47
|
+
### Nullifying attributes
|
|
48
|
+
|
|
49
|
+
Reset the given attributes to `nil` on the duplicate.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
acts_as_ditto do
|
|
53
|
+
nullify :email
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Reset attributes to column defaults
|
|
58
|
+
|
|
59
|
+
Reset the given attributes to their column default on the duplicate.
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
acts_as_ditto do
|
|
63
|
+
reset_to_default :status
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Overwrite attributes with hardcoded values
|
|
68
|
+
|
|
69
|
+
Overwrites the given attributes with hardcoded values on the duplicate.
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
acts_as_ditto do
|
|
73
|
+
override status: "draft"
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Prefix or Suffix attributes
|
|
78
|
+
|
|
79
|
+
Prepend or append a string to an attributes current value.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
acts_as_ditto do
|
|
83
|
+
prefix :name, "Copy of "
|
|
84
|
+
suffix :name, " (Copy)"
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Transform attribute values
|
|
89
|
+
|
|
90
|
+
Transform attribute values with a block, yielded the record and the attribute's
|
|
91
|
+
original value.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
acts_as_ditto do
|
|
95
|
+
transform :secret_number do |_record, old_value|
|
|
96
|
+
old_value.reverse
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Inspiration
|
|
102
|
+
|
|
103
|
+
Ditto was inspired by [amoeba](https://github.com/amoeba-rb/amoeba), which solves the same problem with similar DSL logic.
|
|
104
|
+
|
|
105
|
+
Ditto is opt-in rather than opt-out: you list exactly which associations to
|
|
106
|
+
clone with `clone_associations`, instead of enabling everything and excluding
|
|
107
|
+
what you don't want.
|
|
108
|
+
|
|
109
|
+
## Contributing
|
|
110
|
+
|
|
111
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/njaeggi/acts_as_ditto. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/njaeggi/acts_as_ditto/blob/master/CODE_OF_CONDUCT.md).
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
+
|
|
8
|
+
require "rubocop/rake_task"
|
|
9
|
+
|
|
10
|
+
RuboCop::RakeTask.new
|
|
11
|
+
|
|
12
|
+
namespace :spec do
|
|
13
|
+
desc "Run the spec suite against every supported database adapter"
|
|
14
|
+
task :all do
|
|
15
|
+
adapters = { "sqlite3" => nil, "postgresql" => "postgresql", "mysql" => "mysql" }
|
|
16
|
+
|
|
17
|
+
adapters.each do |db, bundle_with|
|
|
18
|
+
puts "\n Running specs against #{db}"
|
|
19
|
+
env = { "DB" => db }
|
|
20
|
+
env["BUNDLE_WITH"] = bundle_with if bundle_with
|
|
21
|
+
|
|
22
|
+
system(env, "bundle install --quiet") || abort("bundle install failed for #{db}")
|
|
23
|
+
system(env, "bundle exec rspec") || abort("Specs failed against #{db}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
task default: %i[spec rubocop]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
|
|
5
|
+
require_relative "ditto/version"
|
|
6
|
+
require_relative "ditto/configuration"
|
|
7
|
+
require_relative "ditto/duplicator"
|
|
8
|
+
require_relative "ditto/acts_as_ditto"
|
|
9
|
+
|
|
10
|
+
module Ditto
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
ActiveSupport.on_load(:active_record) do
|
|
15
|
+
include Ditto::ActsAsDitto
|
|
16
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module Ditto
|
|
6
|
+
# Add the ditto instance methods
|
|
7
|
+
module InstanceMethods
|
|
8
|
+
def ditto
|
|
9
|
+
Ditto::Duplicator.new(self, self.class.ditto_configuration).duplicate
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def ditto!
|
|
13
|
+
ditto.tap(&:save!)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Adds `acts_as_ditto` to ActiveRecord models.
|
|
18
|
+
module ActsAsDitto
|
|
19
|
+
extend ActiveSupport::Concern
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
def acts_as_ditto(&block)
|
|
23
|
+
configuration = Ditto::Configuration.new
|
|
24
|
+
configuration.instance_eval(&block) if block
|
|
25
|
+
|
|
26
|
+
class_attribute :ditto_configuration, instance_writer: false, default: configuration
|
|
27
|
+
include Ditto::InstanceMethods
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ditto
|
|
4
|
+
# Holds the ditto configuration for the model
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_reader :cloned_associations, :nullified_attributes, :static_values,
|
|
7
|
+
:prefixes, :suffixes, :transformations, :defaulted_attributes
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@cloned_associations = []
|
|
11
|
+
@nullified_attributes = []
|
|
12
|
+
@static_values = {}
|
|
13
|
+
@prefixes = {}
|
|
14
|
+
@suffixes = {}
|
|
15
|
+
@transformations = {}
|
|
16
|
+
@defaulted_attributes = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def clone_associations(*names)
|
|
20
|
+
@cloned_associations.concat(names.map(&:to_sym))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def nullify(*attributes)
|
|
24
|
+
@nullified_attributes.concat(attributes.map(&:to_sym))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def override(attributes = {})
|
|
28
|
+
@static_values.merge!(attributes.transform_keys(&:to_sym))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def prefix(attribute, value)
|
|
32
|
+
@prefixes[attribute.to_sym] = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def suffix(attribute, value)
|
|
36
|
+
@suffixes[attribute.to_sym] = value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset_to_default(*attributes)
|
|
40
|
+
@defaulted_attributes.concat(attributes.map(&:to_sym))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def transform(attribute, &block)
|
|
44
|
+
raise ArgumentError, "transform requires a block" unless block
|
|
45
|
+
|
|
46
|
+
@transformations[attribute.to_sym] = block
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ditto
|
|
4
|
+
# Builds a duplicate of a record based on `Ditto::Configuration`.
|
|
5
|
+
class Duplicator
|
|
6
|
+
def initialize(record, configuration)
|
|
7
|
+
@record = record
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def duplicate
|
|
12
|
+
new_record = @record.dup
|
|
13
|
+
|
|
14
|
+
reset_attributes_to_default(new_record)
|
|
15
|
+
nullify_attributes(new_record)
|
|
16
|
+
apply_static_values(new_record)
|
|
17
|
+
apply_prefixes(new_record)
|
|
18
|
+
apply_suffixes(new_record)
|
|
19
|
+
apply_transformations(new_record)
|
|
20
|
+
clone_associations(new_record)
|
|
21
|
+
|
|
22
|
+
new_record
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def reset_attributes_to_default(new_record)
|
|
28
|
+
@configuration.defaulted_attributes.each do
|
|
29
|
+
new_record[_1] = new_record.class.column_defaults[_1.to_s]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def nullify_attributes(new_record)
|
|
34
|
+
@configuration.nullified_attributes.each { new_record[_1] = nil }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def apply_static_values(new_record)
|
|
38
|
+
@configuration.static_values.each { |attribute, value| new_record[attribute] = value }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply_prefixes(new_record)
|
|
42
|
+
@configuration.prefixes.each do |attribute, value|
|
|
43
|
+
new_record[attribute] = "#{value}#{new_record[attribute]}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply_suffixes(new_record)
|
|
48
|
+
@configuration.suffixes.each do |attribute, value|
|
|
49
|
+
new_record[attribute] = "#{new_record[attribute]}#{value}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_transformations(new_record)
|
|
54
|
+
@configuration.transformations.each do |attribute, block|
|
|
55
|
+
new_record[attribute] = block.call(@record, @record[attribute])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clone_associations(new_record)
|
|
60
|
+
@configuration.cloned_associations.each do |name|
|
|
61
|
+
reflection = @record.class.reflect_on_association(name)
|
|
62
|
+
next unless reflection
|
|
63
|
+
|
|
64
|
+
if reflection.collection?
|
|
65
|
+
clone_collection_association(new_record, name)
|
|
66
|
+
else
|
|
67
|
+
clone_singular_association(new_record, name)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def clone_collection_association(new_record, name)
|
|
73
|
+
@record.public_send(name).each do |associated_record|
|
|
74
|
+
new_record.public_send(name) << duplicate_of(associated_record)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def clone_singular_association(new_record, name)
|
|
79
|
+
associated_record = @record.public_send(name)
|
|
80
|
+
return unless associated_record
|
|
81
|
+
|
|
82
|
+
new_record.public_send(:"#{name}=", duplicate_of(associated_record))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Duplicates an associated record with its own ditto configuration
|
|
86
|
+
# if it has one, otherwise uses an empty ditto configuration which
|
|
87
|
+
# mirros a plain dup
|
|
88
|
+
def duplicate_of(record)
|
|
89
|
+
configuration =
|
|
90
|
+
if record.class.respond_to?(:ditto_configuration)
|
|
91
|
+
record.class.ditto_configuration
|
|
92
|
+
else
|
|
93
|
+
Configuration.new
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
self.class.new(record, configuration).duplicate
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: acts_as_ditto
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Niklas Jäggi
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-28 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.1'
|
|
27
|
+
description: Ditto adds an acts_as_ditto DSL to ActiveRecord models for duplicating
|
|
28
|
+
records with custom control over nullified attributes, static overrides, transformations,
|
|
29
|
+
and recursively cloned associations.
|
|
30
|
+
email:
|
|
31
|
+
- jaeggi@puzzle.ch
|
|
32
|
+
executables: []
|
|
33
|
+
extensions: []
|
|
34
|
+
extra_rdoc_files: []
|
|
35
|
+
files:
|
|
36
|
+
- CHANGELOG.md
|
|
37
|
+
- CODE_OF_CONDUCT.md
|
|
38
|
+
- LICENSE.txt
|
|
39
|
+
- README.md
|
|
40
|
+
- Rakefile
|
|
41
|
+
- lib/acts_as_ditto.rb
|
|
42
|
+
- lib/ditto/acts_as_ditto.rb
|
|
43
|
+
- lib/ditto/configuration.rb
|
|
44
|
+
- lib/ditto/duplicator.rb
|
|
45
|
+
- lib/ditto/version.rb
|
|
46
|
+
- sig/acts_as_ditto.rbs
|
|
47
|
+
homepage: https://github.com/njaeggi/acts_as_ditto
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
homepage_uri: https://github.com/njaeggi/acts_as_ditto
|
|
52
|
+
source_code_uri: https://github.com/njaeggi/acts_as_ditto/tree/main
|
|
53
|
+
changelog_uri: https://github.com/njaeggi/acts_as_ditto/blob/main/CHANGELOG.md
|
|
54
|
+
post_install_message:
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: 3.2.0
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 3.5.3
|
|
70
|
+
signing_key:
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Configurable ActiveRecord duplication.
|
|
73
|
+
test_files: []
|