activemodel_object_info 0.4.0 → 0.4.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 80913d0545f39b6b56ea20826e6c5da37c3fbec7645f988c5439f85f6ac2eedc
4
- data.tar.gz: 8f839ed3448156219e6c4da35c9eb69d9eb7d4dca97ef48f1c89b937ea2a782e
3
+ metadata.gz: 1096a56e9db6886e83460c0015079b2a583e940744d2be9b5bc2a2d6461943aa
4
+ data.tar.gz: c91020d37e00fad63a389db8a2246b7358ba814d303a08466614c34c0dad5c34
5
5
  SHA512:
6
- metadata.gz: a8c1005c3e1887dd377deb8ab79d1d9bad079dea09ac81e24ff484e59efdb44c3e3c6c7ba4741237954d944f0b5780ff00f9e7530ebe465c603eedc9f0aeb5a1
7
- data.tar.gz: 5bd16bc43f2c13b58bfa037ee6505777d80a97d455b7744d75f32cd928348a1d2d0e0cc638acc0070662b42792f83972c365ccd00c81860980f05196afc6bd32
6
+ metadata.gz: 1e17eda2eab30babf92e4f802329f048a1f40ad2e2ea4e226a1671543243315262104a9f806569b032a106c9473c2d1f919017f52b667b32f9901fd25b4510ff
7
+ data.tar.gz: 54f2f7c502906709755d31cb7346bf195f3fba28bd9f0408b8c4f3049b0007bf9eea7c618f461621b9bdaccd2c31d23390fca8f9b3296566093c1c727ca865de
data/.rubocop.yml CHANGED
@@ -39,6 +39,7 @@ Metrics/ModuleLength:
39
39
  Max: 600
40
40
  Metrics/PerceivedComplexity:
41
41
  Max: 50
42
+
42
43
  Metrics/CyclomaticComplexity:
43
44
  Max: 50
44
45
 
data/CHANGELOG CHANGED
@@ -1,6 +1,12 @@
1
1
  # Changelog
2
2
  All notable changes to this project will be documented in this file.
3
3
 
4
+ ## [0.4.1] - 2026-07-28
5
+ ### Added
6
+ - **Base**: Added support for nested output of associated objects via `includes` option.
7
+ - **Base**: Added support for contextual formats via `context` parameter and `INSTANCE_INFO_#{CONTEXT}` constants.
8
+ - **DeletedOperation**: Added `restore` and `restore!` methods to undelete soft-deleted records.
9
+
4
10
  ## [0.4.0] - 2026-07-28
5
11
  ### Fixed
6
12
  - Fixed Ruby 3.x keyword arguments compatibility issue for `instance_info` method, while keeping 100% backward compatibility for existing usages.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- activemodel_object_info (0.4.0)
4
+ activemodel_object_info (0.4.2)
5
5
  activesupport
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -33,7 +33,7 @@
33
33
  Add this line to your application's Gemfile:
34
34
 
35
35
  ```ruby
36
- gem 'activemodel-object-info', '~> 0.4.0'
36
+ gem 'activemodel-object-info', '~> 0.4.2'
37
37
  ```
38
38
 
39
39
  And then execute:
@@ -65,6 +65,11 @@ class User < ApplicationRecord
65
65
  { name: :virtual_field, type: :abstract, filter: ->(*) { "#{id}-#{name}" } } # Virtual field
66
66
  ]
67
67
  }.freeze
68
+
69
+ # Define contextual output configuration (Must be named INSTANCE_INFO_{CONTEXT})
70
+ INSTANCE_INFO_DETAIL = {
71
+ only: [:id, :name, :email, :phone]
72
+ }.freeze
68
73
  end
69
74
  ```
70
75
 
@@ -73,13 +78,49 @@ end
73
78
  ```ruby
74
79
  user = User.first
75
80
 
76
- # Use the default INSTANCE_INFO constant
81
+ # 1. Use the default INSTANCE_INFO constant
77
82
  user.instance_info
78
83
  # => { id: 1, user_name: "John", status: "Active", created_at: "2026-07-28", virtual_field: "1-John" }
79
84
 
80
- # Override at runtime (Supports Ruby 2.x Hash and Ruby 3.x Keyword Arguments)
85
+ # 2. Contextual output: Pass `context` to automatically use the corresponding INSTANCE_INFO_{CONTEXT} constant
86
+ user.instance_info(context: :detail)
87
+ # => { id: 1, name: "John", email: "a@b.com", phone: "12345" }
88
+
89
+ # 3. Override at runtime (Supports Ruby 2.x Hash and Ruby 3.x Keyword Arguments)
81
90
  user.instance_info(only: [:id, :name])
82
91
  # => { id: 1, name: "John" }
92
+
93
+ # 4. Exclude specific fields (except)
94
+ # By default, instance_info filters out deleted, deleted_by, and deleted_at.
95
+ # You can override this behavior by passing an except array:
96
+ user.instance_info(except: [:status, :created_at])
97
+ # => { id: 1, user_name: "John", virtual_field: "1-John" }
98
+
99
+ # 5. Nested output for Associations (includes)
100
+ user.instance_info(
101
+ only: [:id, :name],
102
+ includes: {
103
+ profile: { only: [:avatar_url, :bio] }, # Single instance (has_one / belongs_to)
104
+ roles: { only: [:role_name] } # Collection (has_many)
105
+ }
106
+ )
107
+ # => {
108
+ # id: 1, name: "John",
109
+ # profile: { avatar_url: "...", bio: "..." },
110
+ # roles: [{ role_name: "admin" }, { role_name: "editor" }]
111
+ # }
112
+ ```
113
+
114
+ **Formatting Options:**
115
+ For `Date`, `Time`, and `DateTime` fields, multiple formatting strategies are supported:
116
+ - `format: :standard`: Keeps the original object, does not convert to string
117
+ - `format: :full` / `:min` / `:date` / `:month` / `:year`: Uses built-in shortcut formats (e.g. `:date` outputs `%Y-%m-%d`)
118
+ - `format: '%Y/%m/%d'`: Original strftime string
119
+ - `format: ->(v) { "#{v.year}-#{v.month}-#{v.day}" }`: Pass a `Proc` / `Lambda` for completely custom formatting (supported since `0.4.2`)
120
+
121
+ *Global Date Formatting*: In addition to specifying `format` for individual fields in the `attributes` array, you can set a global default by passing the `datetime_format` parameter:
122
+ ```ruby
123
+ user.instance_info(datetime_format: :date) # All time fields will use the :date format by default
83
124
  ```
84
125
 
85
126
  ### 2. Migration Macros (TableDefinition)
@@ -97,16 +138,20 @@ class CreateUsers < ActiveRecord::Migration[6.1]
97
138
  create_table :users do |t|
98
139
  t.string :name
99
140
 
100
- # Generates full audit suite:
101
- # created_by, created_at, updated_by, updated_at, deleted(int), deleted_by, deleted_at
141
+ # 1. Generates full audit suite:
142
+ # If no arguments are passed, it generates fields for 'created', 'updated', and 'deleted' operations.
143
+ # Generates: created_by, created_at, updated_by, updated_at, deleted(int), deleted_by, deleted_at
144
+ # Note: The generated timestamp and operator fields are indexed by default (index: true).
102
145
  t.generate_operations
103
146
 
104
- # Or generate specific custom audit fields:
147
+ # 2. Or generate specific custom audit fields:
105
148
  # Generates: audit_by (bigint), audit_at (datetime)
106
149
  t.operation_columns(:audit)
107
150
 
151
+ # 3. Highly customized prefixes, suffixes, and column generation control:
108
152
  # Generates: my_review_user (bigint), my_review_time (datetime)
109
- t.operation_columns(:review, operator_prefix: 'my_', operator_suffix: '_user', timestamp_suffix: '_time')
153
+ # with_operator / with_timestamp can be used to control whether the operator or timestamp fields are generated.
154
+ t.operation_columns(:review, operator_prefix: 'my_', operator_suffix: '_user', timestamp_suffix: '_time', with_operator: true, with_timestamp: true)
110
155
  end
111
156
  end
112
157
  end
@@ -152,6 +197,19 @@ user.soft_delete(user_id: current_user.id, refresh_updated: true)
152
197
 
153
198
  # 4. Strict Soft Delete (raises exception on failure):
154
199
  user.soft_delete!(user_id: current_user.id)
200
+
201
+ # 5. Restore soft-deleted record (Undelete):
202
+ # Note: Deleted records are hidden by default_scope, so you must use unscoped to find them
203
+ deleted_user = User.unscoped.find(1)
204
+
205
+ # This resets the `deleted` flag to 0 and clears `deleted_by` and `deleted_at`
206
+ deleted_user.restore
207
+
208
+ # 6. Restore and refresh updated_at with user_id:
209
+ deleted_user.restore(user_id: current_user.id, refresh_updated: true)
210
+
211
+ # 7. Strict Restore (calls save! and raises exception on failure):
212
+ deleted_user.restore!(user_id: current_user.id)
155
213
  ```
156
214
 
157
215
  ## Development & Testing
data/README.zh-CN.md CHANGED
@@ -33,7 +33,7 @@
33
33
  将以下代码添加到你项目中的 Gemfile 里:
34
34
 
35
35
  ```ruby
36
- gem 'activemodel-object-info', '~> 0.4.0'
36
+ gem 'activemodel-object-info', '~> 0.4.2'
37
37
  ```
38
38
 
39
39
  然后执行依赖安装:
@@ -54,7 +54,7 @@ $ bundle install
54
54
  class User < ApplicationRecord
55
55
  include ActivemodelObjectInfo::Base
56
56
 
57
- # 定义默认的输出配置选项
57
+ # 默认的输出配置选项
58
58
  INSTANCE_INFO = {
59
59
  only: [:id, :name, :status, :created_at],
60
60
  attributes: [
@@ -65,6 +65,11 @@ class User < ApplicationRecord
65
65
  { name: :virtual_field, type: :abstract, filter: ->(*) { "#{id}-#{name}" } } # 根据其他字段合成的虚拟字段
66
66
  ]
67
67
  }.freeze
68
+
69
+ # 自定义场景输出配置(名称格式必须为 INSTANCE_INFO_场景名)
70
+ INSTANCE_INFO_DETAIL = {
71
+ only: [:id, :name, :email, :phone]
72
+ }.freeze
68
73
  end
69
74
  ```
70
75
 
@@ -73,13 +78,49 @@ end
73
78
  ```ruby
74
79
  user = User.first
75
80
 
76
- # 不传参时,默认使用模型中的 INSTANCE_INFO 常量配置
81
+ # 1. 不传参时,默认使用模型中的 INSTANCE_INFO 常量配置
77
82
  user.instance_info
78
83
  # => { id: 1, user_name: "John", status: "活跃", created_at: "2026-07-28", virtual_field: "1-John" }
79
84
 
80
- # 运行时覆盖配置(完美兼容 Ruby 2.x Hash 传参和 Ruby 3.x 的关键字参数)
85
+ # 2. 场景化输出:通过 context 参数指定场景,自动读取对应的 INSTANCE_INFO_DETAIL 常量
86
+ user.instance_info(context: :detail)
87
+ # => { id: 1, name: "John", email: "a@b.com", phone: "12345" }
88
+
89
+ # 3. 运行时覆盖配置(完美兼容 Ruby 2.x 的 Hash 传参和 Ruby 3.x 的关键字参数)
81
90
  user.instance_info(only: [:id, :name])
82
91
  # => { id: 1, name: "John" }
92
+
93
+ # 4. 排除输出字段 (except)
94
+ # 默认情况下,instance_info 会自动过滤掉 deleted, deleted_by, deleted_at 字段。
95
+ # 你可以通过传入 except 数组覆盖默认行为,过滤掉你不需要的字段:
96
+ user.instance_info(except: [:status, :created_at])
97
+ # => { id: 1, user_name: "John", virtual_field: "1-John" }
98
+
99
+ # 5. 嵌套输出关联对象 (includes)
100
+ user.instance_info(
101
+ only: [:id, :name],
102
+ includes: {
103
+ profile: { only: [:avatar_url, :bio] }, # has_one / belongs_to 单实例
104
+ roles: { only: [:role_name] } # has_many 集合实例
105
+ }
106
+ )
107
+ # => {
108
+ # id: 1, name: "John",
109
+ # profile: { avatar_url: "...", bio: "..." },
110
+ # roles: [{ role_name: "admin" }, { role_name: "editor" }]
111
+ # }
112
+ ```
113
+
114
+ **支持的时间格式化 (`format`):**
115
+ 对于 `Date`、`Time`、`DateTime` 类型的字段,支持以下多种格式化策略:
116
+ - `format: :standard`:保持原生对象,不转字符串
117
+ - `format: :full` / `:min` / `:date` / `:month` / `:year`:使用内置快捷格式,例如 `:date` 会输出 `%Y-%m-%d`
118
+ - `format: '%Y/%m/%d'`:使用原生 strftime 字符串
119
+ - `format: ->(v) { "#{v.year}年#{v.month}月#{v.day}日" }`:传入 `Proc` / `Lambda` 实现完全自定义格式化 (自 `0.4.2` 起支持)
120
+
121
+ *全局时间格式化*:除了在 `attributes` 数组内对单一字段指定 `format` 外,你还可以通过传入 `datetime_format` 参数进行全局默认设定:
122
+ ```ruby
123
+ user.instance_info(datetime_format: :date) # 全局的时间默认都会使用 :date 格式
83
124
  ```
84
125
 
85
126
  ### 2. 数据库迁移宏 (TableDefinition)
@@ -95,16 +136,20 @@ class CreateUsers < ActiveRecord::Migration[6.1]
95
136
  t.string :name
96
137
 
97
138
  # 1. 批量生成全套生命周期审计字段:
139
+ # t.generate_operations 不传参时默认生成 created, updated, deleted 三大操作相关字段。
98
140
  # 将会自动生成: created_by, created_at, updated_by, updated_at, deleted(int), deleted_by, deleted_at
141
+ # 注意:生成的时间戳和操作人字段默认都是带数据库索引的 (index: true)
99
142
  t.generate_operations
100
143
 
101
144
  # 2. 或者生成指定的自定义审计字段:
145
+ # t.operation_columns 会生成 :audit 对应的操作人(bigint)与操作时间(datetime)
102
146
  # 将会自动生成: audit_by (bigint), audit_at (datetime)
103
147
  t.operation_columns(:audit)
104
148
 
105
- # 3. 甚至高度自定义前后缀:
149
+ # 3. 甚至高度自定义前后缀与控制字段生成:
106
150
  # 将会自动生成: my_review_user (bigint), my_review_time (datetime)
107
- t.operation_columns(:review, operator_prefix: 'my_', operator_suffix: '_user', timestamp_suffix: '_time')
151
+ # with_operator / with_timestamp 可以分别控制是否生成对应的操作人或操作时间字段
152
+ t.operation_columns(:review, operator_prefix: 'my_', operator_suffix: '_user', timestamp_suffix: '_time', with_operator: true, with_timestamp: true)
108
153
  end
109
154
  end
110
155
  end
@@ -150,6 +195,19 @@ user.soft_delete(user_id: current_user.id, refresh_updated: true)
150
195
 
151
196
  # 4. 强校验软删除 (底层调用 save!,失败时抛出异常):
152
197
  user.soft_delete!(user_id: current_user.id)
198
+
199
+ # 5. 恢复已软删除的数据 (撤销软删除):
200
+ # 注意:已被软删除的数据会被 default_scope 过滤,因此需要使用 unscoped 查出
201
+ deleted_user = User.unscoped.find(1)
202
+
203
+ # 执行恢复操作,此操作会将 deleted 标记重置为 0,并清空 deleted_by 和 deleted_at
204
+ deleted_user.restore
205
+
206
+ # 6. 恢复并同时记录更新人与更新时间:
207
+ deleted_user.restore(user_id: current_user.id, refresh_updated: true)
208
+
209
+ # 7. 强校验恢复 (底层调用 save!,失败时抛出异常):
210
+ deleted_user.restore!(user_id: current_user.id)
153
211
  ```
154
212
 
155
213
  ## 开发与测试
@@ -41,6 +41,6 @@ Gem::Specification.new do |spec|
41
41
  spec.add_development_dependency 'simplecov'
42
42
  spec.add_development_dependency 'yard'
43
43
  spec.metadata = {
44
- 'rubygems_mfa_required' => 'false',
44
+ 'rubygems_mfa_required' => 'true',
45
45
  }
46
46
  end
@@ -32,6 +32,8 @@ module ActivemodelObjectInfo
32
32
  # @author shiner527 <shiner527@hotmail.com>
33
33
  #
34
34
  # [Changelog]
35
+ # [2026-07-28] 支持通过 Proc / Lambda 完全自定义时间格式化 (shiner527)
36
+ # [2026-07-28] 支持关联对象嵌套输出 (includes) 与场景化输出配置 (context) (shiner527)
35
37
  # [2026-07-28] 补充完整 YARD 文档及核心注释,重构 instance_info 签名以兼容 Ruby 3.x 关键字参数 (shiner527)
36
38
  # [2021-04-19] 创建基础模块,提供 instance_info 数据格式化输出功能 (shiner527)
37
39
  #
@@ -52,24 +54,80 @@ module ActivemodelObjectInfo
52
54
  # ===== 方法定义 =====
53
55
 
54
56
  #
55
- # 对象信息输出。主要返回给前端一个可用的散列(会被转化为JSON格式)格式的信息并传递给前端。
57
+ # 对象信息输出。主要返回给前端一个可用的散列(会被转化为 JSON 格式)格式的信息并传递给前端。
58
+ #
59
+ # @since 0.1.0 基础功能
60
+ # @since 0.4.0 兼容 Ruby 3.x 关键字参数,支持 :context 与 :includes
61
+ # @since 0.4.2 时间格式化支持 Proc/Lambda 完全自定义
56
62
  #
57
63
  # @param [Hash] options_hash 设置选项(传统位置参数 Hash),如果传入将被与 keyword_options 合并
58
64
  # @param [Hash] keyword_options 设置选项(Ruby 3 关键字参数)
65
+ #
66
+ # @option keyword_options [Symbol, String] :context 场景上下文名称。如果提供,将优先读取 "INSTANCE_INFO_#{context.upcase}" 常量。
59
67
  # @option keyword_options [Array<Symbol, Hash>] :attributes 具体每一项输出的设置数组。每个元素既可以是标识符实例也可以是一个散列实例。
60
- # 如果是标识符实例,则表示输出该属性。如果是一个散列实例,则按照散列中的设定值去生成对应的内容。
61
- # @option keyword_options [Array<Symbol>] :only 给出具体可以用来输出的字段属性名数组。
62
- # @option keyword_options [Array<Symbol>] :except 给出需要被排除输出的字段属性名数组。
63
- # @option keyword_options [String, Symbol] :datetime_format 全局的时间格式设置。
68
+ # 如果是标识符实例,则表示输出该属性。如果是一个散列实例,则按照散列中的设定值去生成对应的内容。
69
+ # 散列中支持如下配置项:
70
+ # - :name (Symbol) 对应的原始字段名或方法名
71
+ # - :as (Symbol) 别名,输出时会使用该别名代替原始字段名
72
+ # - :type (Symbol) 当配置为 :abstract 时表示虚拟字段,不再去对象中调用对应的属性
73
+ # - :filter (Proc, Symbol, Object) 过滤器,如果是 Proc,则通过 instance_exec 执行;若是 Symbol,则调用同名方法;否则直接作为静态值返回
74
+ # - :format (Symbol, String, Proc) 对于时间字段进行格式化。支持预设 Symbol(:full, :min, :date, :month, :year, :standard),原生 strftime 字符串,或通过 Proc 自定义输出
75
+ # @option keyword_options [Array<Symbol>] :only 给出具体可以用来输出的字段属性名数组。只有在 only 中的字段才会被输出。
76
+ # @option keyword_options [Array<Symbol>] :except 给出需要被排除输出的字段属性名数组。默认排除 `deleted` 相关的审计字段。
77
+ # @option keyword_options [String, Symbol] :datetime_format 全局的时间格式设置,作用于所有时间类型但会被字段级的 format 覆盖。
78
+ # @option keyword_options [Hash] :includes 需要嵌套输出的关联对象配置,以 Hash 形式递归定义子对象的输出规则(如 `{ profile: { only: [:avatar] } }`)。
79
+ #
80
+ # @example 基本使用
81
+ # user.instance_info(only: [:id, :name])
82
+ # # => { id: 1, name: "Alice" }
83
+ #
84
+ # @example 复杂字段重命名与过滤
85
+ # user.instance_info(
86
+ # attributes: [
87
+ # :id,
88
+ # { name: :first_name, as: :name },
89
+ # { name: :age, type: :abstract, filter: ->(v) { 2026 - birth_year } }
90
+ # ]
91
+ # )
92
+ # # => { id: 1, name: "Alice", age: 30 }
93
+ #
94
+ # @example 时间自定义格式化
95
+ # user.instance_info(
96
+ # attributes: [
97
+ # { name: :created_at, format: ->(v) { "#{v.year}年#{v.month}月#{v.day}日" } }
98
+ # ]
99
+ # )
100
+ # # => { created_at: "2026年7月28日" }
64
101
  #
65
- # @return [Hash] 返回的处理过的该对象的信息散列。
102
+ # @example 嵌套关联输出
103
+ # user.instance_info(
104
+ # only: [:id, :name],
105
+ # includes: {
106
+ # profile: { only: [:avatar_url] },
107
+ # roles: { only: [:id, :role_name] }
108
+ # }
109
+ # )
110
+ # # => { id: 1, name: "Alice", profile: { avatar_url: "..." }, roles: [{ id: 1, role_name: "admin" }] }
111
+ #
112
+ # @return [Hash] 返回处理过的该对象的信息散列,键名统一转化为 Symbol。
113
+ #
114
+ # @raise [NoMethodError] 当尝试获取未定义的属性,且未将该属性设为 `type: :abstract` 或配置 `filter` 时可能抛出。
66
115
  #
67
116
  def instance_info(options_hash = nil, **keyword_options)
68
117
  # 合并传统参数与关键字参数,完美兼容 Ruby 2.x 的 Hash 传参和 Ruby 3.x 的 **kwargs 传参
69
118
  options = (options_hash || {}).merge(keyword_options)
70
119
 
71
- # 尝试获取当前类上配置的 INSTANCE_INFO 常量作为默认选项
72
- options = "#{self.class}::INSTANCE_INFO".safe_constantize || {} if options.blank?
120
+ # 尝试获取当前类上配置的常量作为默认选项
121
+ # 优先级:传入的参数 options > 场景常量 INSTANCE_INFO_#{CONTEXT} > 默认常量 INSTANCE_INFO
122
+ if options.blank? || (options.keys.map(&:to_sym) == [:context] && options[:context].present?)
123
+ context_name = options[:context].to_s.upcase if options[:context].present?
124
+
125
+ # 尝试寻找带场景的常量
126
+ options = ("#{self.class}::INSTANCE_INFO_#{context_name}".safe_constantize if context_name.present?)
127
+
128
+ # 如果带场景的常量不存在,或者没传场景,降级寻找默认常量
129
+ options ||= "#{self.class}::INSTANCE_INFO".safe_constantize || {}
130
+ end
73
131
 
74
132
  # 将 options 的 key 转为 symbol,避免传入字符串 key 导致匹配不到
75
133
  options = options.deep_symbolize_keys
@@ -98,9 +156,36 @@ module ActivemodelObjectInfo
98
156
  # 这里保证了要引入的类中含有 attributes 方法,且为 Hash 类型
99
157
 
100
158
  # 补丁:如果传入了 only_attributes,但是 options[:attributes] 是空(即调用方没传),
101
- # 我们需要将 only_attributes 中的字段视为基础配置,以确保只包含这几个字段的正常输出
159
+ # 并且当前并没有匹配到任何场景常量,我们需要将 only_attributes 中的字段视为基础配置,以确保只包含这几个字段的正常输出
102
160
  attribute_configs = only_attributes.map { |attr| { name: attr } } if attribute_configs.empty? && only_attributes.present?
103
161
 
162
+ # 补丁 2:如果在处理完毕后,attribute_configs 仍然为空,并且 output_attributes 存在
163
+ # 则说明我们正在执行类似 default options 的全量输出或者包含了所有字段的嵌套输出
164
+ # 此时,我们需要基于 output_attributes 自动生成 attribute_configs,但需要保留 options[:attributes] 中已经配置了 format 等逻辑的字段配置
165
+ if attribute_configs.empty? && output_attributes.present?
166
+ # 取出最初预设在 options 里的属性配置
167
+ preset_configs = options[:attributes] || []
168
+
169
+ # 获取 options 中显式定义了的所有字段的原始名和别名
170
+ preset_attr_names = preset_configs.flat_map do |config|
171
+ case config
172
+ when ::Hash
173
+ [config[:name], config[:as]].compact.map(&:to_sym)
174
+ when ::Symbol, ::String
175
+ config.to_sym
176
+ end
177
+ end.compact.uniq
178
+
179
+ supplementary_configs = (output_attributes - preset_attr_names).map { |attr| { name: attr } }
180
+
181
+ # 将预设的规则配置和自动补充的基础配置合并,作为最终的 attribute_configs
182
+ # 注意:为了避免重复处理以及处理顺序问题,我们统一使用预先配置,并在末尾追加没有配置的字段
183
+ attribute_configs = preset_configs + supplementary_configs
184
+
185
+ # 移除 attribute_configs 中的重复项 (如果存在)
186
+ attribute_configs.uniq!
187
+ end
188
+
104
189
  attribute_configs.each do |attr_config|
105
190
  # 根据配置项的类型(Symbol/String 或 Hash)提取字段名称和具体配置
106
191
  if [::String, ::Symbol].any? { |attr_class| attr_config.is_a?(attr_class) }
@@ -118,13 +203,14 @@ module ActivemodelObjectInfo
118
203
  raw_name = current_attr_config[:as].present? ? current_attr_config[:as].to_sym : attribute_name
119
204
  filter = current_attr_config[:filter]
120
205
 
121
- # 取出对象中的实际值
206
+ # 获取真正对应的属性的名称和其值
122
207
  k = raw_name
123
- # 特殊处理 abstract 类型:当类型为抽象类时,由于可能没有对应的真实属性方法,直接赋值为 nil,后续依赖 filter 来生成实际值
208
+ # 当类型为抽象类时,是不会有对应的方法和属性的,因此直接设置为 nil 并期待之后的 filter 等规则来生成实际值。
124
209
  v = current_attr_config[:type] == :abstract ? nil : __send__(k)
125
210
 
126
211
  # 跳过条件:既不在最终输出名单中,也不是显式声明的 abstract/method 虚拟字段
127
- next unless output_attributes.include?(k) || %i[abstract method].include?(current_attr_config[:type])
212
+ # 注意:需要同时判断别名(attribute_name)和原名(k)是否在需要输出的列表中
213
+ next unless output_attributes.include?(attribute_name) || output_attributes.include?(k) || %i[abstract method].include?(current_attr_config[:type])
128
214
 
129
215
  # 4. 根据类型或 filter 对值进行格式化转换
130
216
  if filter.present?
@@ -142,6 +228,9 @@ module ActivemodelObjectInfo
142
228
  attribute_format = current_attr_config[:format].present? ? current_attr_config[:format] : options[:datetime_format]
143
229
  result[attribute_name] = if attribute_format.to_s == 'standard'
144
230
  v # standard 保持时间对象原生格式
231
+ elsif attribute_format.is_a?(::Proc)
232
+ # 支持传入 Lambda 或 Proc 进行完全自定义的格式化
233
+ instance_exec(v, &attribute_format)
145
234
  elsif attribute_format.present?
146
235
  # 匹配内置的别名格式 (full/min/date/month/year),否则作为自定义 strftime 字符串处理
147
236
  if %i[full min date month year].include?(attribute_format.to_sym)
@@ -159,8 +248,53 @@ module ActivemodelObjectInfo
159
248
  end
160
249
  end
161
250
 
162
- # 统一将结果第一层的键名转化为 Symbol 格式并返回
251
+ # 统一将结果第一层的键名转化为 Symbol 格式
163
252
  result.symbolize_keys!
253
+
254
+ # 5. 处理嵌套关联对象 (includes)
255
+ format_associations!(result, options)
256
+
257
+ result
258
+ end
259
+
260
+ private
261
+
262
+ #
263
+ # 处理并格式化嵌套关联对象。
264
+ #
265
+ # @api private
266
+ #
267
+ # @note 该方法仅被 `instance_info` 内部调用,用于将配置中 `includes` 的选项应用到实际的关联对象上。
268
+ # 之所以保持私有(private),是因为此方法直接修改了 `instance_info` 输出的 `result` 散列,
269
+ # 其行为和状态与 `instance_info` 的上下文深度绑定,单独对外暴露并无实际意义,反而会破坏对象的封装性。
270
+ #
271
+ # @since 0.4.0
272
+ #
273
+ # @param [Hash] result 当前实例的输出结果散列,关联对象的数据会被注入到该散列中
274
+ # @param [Hash] options 包含 `:includes` 配置的选项散列
275
+ #
276
+ # @return [void]
277
+ #
278
+ def format_associations!(result, options)
279
+ return unless options[:includes].present? && options[:includes].is_a?(::Hash)
280
+
281
+ options[:includes].each do |association_name, assoc_options|
282
+ assoc_options = {} unless assoc_options.is_a?(::Hash)
283
+
284
+ # 安全获取关联对象
285
+ next unless respond_to?(association_name)
286
+
287
+ assoc_obj = __send__(association_name)
288
+
289
+ # 遍历处理关联对象
290
+ if assoc_obj.respond_to?(:map)
291
+ result[association_name.to_sym] = assoc_obj.map do |item|
292
+ item.respond_to?(:instance_info) ? item.instance_info(**assoc_options) : item
293
+ end
294
+ elsif assoc_obj.present?
295
+ result[association_name.to_sym] = assoc_obj.respond_to?(:instance_info) ? assoc_obj.instance_info(**assoc_options) : assoc_obj
296
+ end
297
+ end
164
298
  end
165
299
  end
166
300
  end
@@ -20,21 +20,34 @@ module ActivemodelObjectInfo
20
20
  # DELETED_INVALID_VALUE = true
21
21
  # end
22
22
  #
23
+ # # 1. 默认查询过滤
23
24
  # # 查询数据时会自动应用 default_scope: WHERE is_deleted = false
24
25
  # User.all
25
26
  #
26
- # # 软删除一条记录(必须要传入 user_id)
27
+ # # 2. 软删除记录
28
+ # # 软删除一条记录(必须要传入 user_id 用于审计)
27
29
  # user = User.find(1)
28
30
  # user.soft_delete(user_id: current_user.id, refresh_updated: true)
31
+ # # 此时 user 的 is_deleted 变为 true,同时 deleted_at, deleted_by, updated_by 都会被更新
32
+ #
33
+ # # 3. 恢复软删除的数据
34
+ # # 因为已经被软删除的数据会被 default_scope 过滤,所以需要使用 unscoped 查出
35
+ # deleted_user = User.unscoped.find(1)
36
+ # # 执行恢复操作,同样可以传入 user_id 记录是谁执行的恢复,并选择是否刷新 updated_at
37
+ # deleted_user.restore(user_id: current_user.id, refresh_updated: true)
38
+ # # 此时 user 的 is_deleted 变为 false,同时 deleted_at 和 deleted_by 会被清空,updated_by 会被更新
29
39
  #
30
40
  # 核心重要方法:
31
41
  # - {#soft_delete}:执行软删除并调用 save 更新数据。
32
42
  # - {#soft_delete!}:执行软删除并调用 save! 更新数据(抛出异常)。
43
+ # - {#restore}:恢复已软删除的数据。
44
+ # - {#restore!}:强制恢复已软删除的数据(抛出异常)。
33
45
  # - {#delete_block}:底层通用的删除字段赋值逻辑块。
34
46
  #
35
47
  # @author shiner527 <shiner527@hotmail.com>
36
48
  #
37
49
  # [Changelog]
50
+ # [2026-07-28] 新增 restore 与 restore! 方法以支持软删除数据恢复 (shiner527)
38
51
  # [2026-07-28] 补充完整 YARD 文档、行内注释,清理尾随空格 (shiner527)
39
52
  # [2021-04-19] 创建基础模块,提供软删除与作用域注入功能 (shiner527)
40
53
  #
@@ -50,8 +63,8 @@ module ActivemodelObjectInfo
50
63
 
51
64
  # 2. 注入 default_scope 默认查询作用域:如果模型拥有删除标记字段,则默认查询自动排除被标记的数据
52
65
  default_scope do
53
- _current_model = try(:name).to_s.safe_constantize
54
- where(deleted_field.to_sym => deleted_value_valid) if _current_model.has_attribute?(deleted_field)
66
+ current_model = try(:name).to_s.safe_constantize
67
+ where(deleted_field.to_sym => deleted_value_valid) if current_model.has_attribute?(deleted_field)
55
68
  end
56
69
 
57
70
  # 3. 定义底层的删除逻辑块 (给实例对象的内存属性赋值)
@@ -97,6 +110,43 @@ module ActivemodelObjectInfo
97
110
 
98
111
  save!(touch: opts[:refresh_updated])
99
112
  end
113
+
114
+ # 6. 底层的恢复逻辑块 (给实例对象的内存属性赋值,清除删除标记)
115
+ define_method(:restore_block) do |**options|
116
+ options[:refresh_updated] = options[:refresh_updated].nil? ? false : options[:refresh_updated]
117
+
118
+ updated_by_field = (options[:updated_by_field] || 'updated_by').to_sym
119
+ deleted_by_field = (options[:deleted_by_field] || 'deleted_by').to_sym
120
+ deleted_at_field = (options[:deleted_at_field] || 'deleted_at').to_sym
121
+
122
+ # 核心:恢复状态为有效标记值
123
+ __send__("#{deleted_field}=", deleted_value_valid)
124
+
125
+ # 审计:清除删除人、删除时间,根据配置选择性地设置恢复操作的更新人
126
+ __send__("#{deleted_by_field}=", nil) if respond_to?(deleted_by_field)
127
+ __send__("#{deleted_at_field}=", nil) if respond_to?(deleted_at_field)
128
+ __send__("#{updated_by_field}=", options[:user_id]) if respond_to?(updated_by_field) && options[:user_id].present? && options[:refresh_updated]
129
+ end
130
+
131
+ # 7. 暴露的常规软删除恢复方法
132
+ define_method(:restore) do |**options|
133
+ opts = options.deep_symbolize_keys
134
+ return unless respond_to?(deleted_field)
135
+
136
+ restore_block(**opts)
137
+
138
+ save(touch: opts[:refresh_updated])
139
+ end
140
+
141
+ # 8. 暴露的强校验软删除恢复方法
142
+ define_method(:restore!) do |**options|
143
+ opts = options.deep_symbolize_keys
144
+ return unless respond_to?(deleted_field)
145
+
146
+ restore_block(**opts)
147
+
148
+ save!(touch: opts[:refresh_updated])
149
+ end
100
150
  end
101
151
  end
102
152
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActivemodelObjectInfo
4
4
  module Version
5
- VERSION = '0.4.0'
5
+ VERSION = '0.4.2'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activemodel_object_info
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - shiner
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-28 00:00:00.000000000 Z
11
+ date: 2026-07-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -170,7 +170,7 @@ homepage: https://github.com/shiner527/activemodel-object-info
170
170
  licenses:
171
171
  - MIT
172
172
  metadata:
173
- rubygems_mfa_required: 'false'
173
+ rubygems_mfa_required: 'true'
174
174
  post_install_message:
175
175
  rdoc_options: []
176
176
  require_paths: