plutonium 0.40.0 → 0.41.1

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: ba93910895cf945960fdec47e9ba7026df83718d63e947a68ed4eb78ccd2ff4a
4
- data.tar.gz: fbff28e4bfefc0fb86a85ef6e8c5d8272b2669a0048e7e9465c882bcfa1bb07c
3
+ metadata.gz: db950a1455c885d7bdc26d86b267e95c8a7bd7cc61d5d3b2153d4225e7a40dcc
4
+ data.tar.gz: 6387d54982f72c298861147b167b59d768b32fa5f8ecccaa113c75b2d825df1d
5
5
  SHA512:
6
- metadata.gz: 3c6bf1a8172e6898415f7643e9170e9ca8541980b49346e05a74264b8d19deb94ff3f8b42f5cd1c7a256aa3bb35f112ee542d687b20dfc361a770da5e77b6414
7
- data.tar.gz: a70bdce9041d0e729a65f225c4211a97be8c4c97e378b9138079edcd3282a8a33cae73e2f230b9054f2041ba8dffe6035f32aab4e3ae87fb27ba7b8646cb781e
6
+ metadata.gz: f9a9dcb55e48ce7d86d8b8ee697149a96025cbacee380869188fa6d6af98981456e388ae16c141f5fc7934f44f8c1044aa0696aa9afe83e5749c8733a56bb7a5
7
+ data.tar.gz: 7e1c4765aa8d761ef049e9824574442b534bd1272506d2298993555bbfe25be929c58518876839f7e2be422e3cbd670c4504439f9ad05a3bf6bf14d052468f8e
@@ -83,18 +83,56 @@ Format: `name:type:index_type`
83
83
  | `'ends_at:datetime?'` | Nullable datetime |
84
84
  | `alarm_time:time` | Required time |
85
85
  | `'reminder_time:time?'` | Nullable time |
86
+ | `metadata:json` | JSON field |
87
+ | `settings:jsonb` | JSONB (PostgreSQL) |
88
+ | `external_id:uuid` | UUID field |
86
89
 
87
- ### Decimal with Precision
90
+ ### PostgreSQL-Specific Types
88
91
 
89
- The `{precision,scale}` syntax **only works for decimal types**:
92
+ These types work in both PostgreSQL and SQLite (automatically mapped):
93
+
94
+ | Type | PostgreSQL | SQLite |
95
+ |------|------------|--------|
96
+ | `jsonb` | `jsonb` | `json` |
97
+ | `hstore` | `hstore` | `json` |
98
+ | `uuid` | `uuid` | `string` |
99
+ | `inet` | `inet` | `string` |
100
+ | `cidr` | `cidr` | `string` |
101
+ | `macaddr` | `macaddr` | `string` |
102
+ | `ltree` | `ltree` | `string` |
103
+
104
+ ### Default Values
105
+
106
+ Use `{default:value}` syntax for default values:
107
+
108
+ ```bash
109
+ 'status:string{default:draft}' # String default
110
+ 'active:boolean{default:true}' # Boolean default (true/false/yes/1)
111
+ 'priority:integer{default:0}' # Integer default
112
+ 'rating:float{default:4.5}' # Float default
113
+ 'status:string?{default:pending}' # Nullable with default
114
+ ```
115
+
116
+ ### JSON/JSONB Default Values
117
+
118
+ Supports nested braces for structured defaults:
90
119
 
91
120
  ```bash
92
- 'latitude:decimal{11,8}' # precision: 11, scale: 8
93
- 'amount:decimal{10,2}' # precision: 10, scale: 2
94
- 'latitude:decimal?{11,8}' # nullable with precision
121
+ 'metadata:jsonb{default:{}}' # Empty hash
122
+ 'tags:jsonb{default:[]}' # Empty array
123
+ 'settings:jsonb{default:{"theme":"dark"}}' # Object with values
124
+ 'config:jsonb?{default:{}}' # Nullable with empty hash default
95
125
  ```
96
126
 
97
- **Note**: For default values on other types (boolean, integer, etc.), edit the migration manually.
127
+ Default values are parsed as JSON first. If JSON parsing fails, the value is treated as a string (or coerced based on column type for integers, floats, and booleans).
128
+
129
+ ### Decimal with Precision and Default
130
+
131
+ ```bash
132
+ 'amount:decimal{10,2}' # precision: 10, scale: 2
133
+ 'price:decimal{10,2,default:0}' # with default value
134
+ 'balance:decimal?{15,2,default:0}' # nullable with precision and default
135
+ ```
98
136
 
99
137
  ### References/Associations
100
138
 
@@ -103,6 +141,8 @@ company:belongs_to # Required foreign key
103
141
  'parent:belongs_to?' # Nullable (null: true + optional: true)
104
142
  user:references # Same as belongs_to
105
143
  blogging/post:belongs_to # Cross-package reference
144
+ 'author:belongs_to{class_name:User}' # Custom class_name (author_id -> User)
145
+ 'reviewer:belongs_to?{class_name:User}' # Nullable with class_name
106
146
  ```
107
147
 
108
148
  Nullable references generate:
@@ -179,10 +219,12 @@ t.belongs_to :parent, null: false, foreign_key: {on_delete: :cascade}
179
219
 
180
220
  ### Default Values
181
221
 
222
+ Default values can be set directly in the generator using `{default:value}` syntax (see Field Type Syntax above). For more complex defaults or expressions, edit the migration:
223
+
182
224
  ```ruby
183
225
  t.boolean :is_active, default: true
184
226
  t.integer :status, default: 0
185
- t.integer :count, null: true, default: 0
227
+ t.datetime :published_at, default: -> { "CURRENT_TIMESTAMP" }
186
228
  ```
187
229
 
188
230
  ## Examples
data/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ ## [0.41.1] - 2026-02-09
2
+
3
+ ### 🚀 Features
4
+
5
+ - *(generators)* Support JSON default values for jsonb fields
6
+ ## [0.41.0] - 2026-02-09
7
+
8
+ ### 🚀 Features
9
+
10
+ - *(generators)* Add default value support and improve SQLite compatibility
11
+ - *(generators)* Add class_name option for belongs_to fields
12
+ - *(generators)* Add --policy and --definition flags to conn generator
13
+
14
+ ### 🐛 Bug Fixes
15
+
16
+ - *(generators)* Fix has_many association injection pattern
17
+ - *(generators)* Add missing scripts to package.json in assets generator
18
+ - *(generators)* Remove primary account support for rodauth
19
+ - *(generators)* Use top-level Gem::Version to avoid namespace collision
20
+ - *(ui)* Check resource registration before generating association add URL
21
+
22
+ ### 📚 Documentation
23
+
24
+ - Add class_name option and associations section to generator docs
25
+
26
+ ### 🧪 Testing
27
+
28
+ - *(generators)* Add tests for named rodauth account configuration
29
+ - *(sqlite)* Move type alias tests to core and test actual migrations
1
30
  ## [0.40.0] - 2026-02-04
2
31
 
3
32
  ### 🚀 Features
@@ -1,15 +1,58 @@
1
- # Alias json to jsonb in SQLite migrations
1
+ # Alias PostgreSQL-specific types for SQLite compatibility in migrations
2
+ #
3
+ # This allows using PostgreSQL-specific column types (jsonb, uuid, etc.) in migrations
4
+ # while developing with SQLite. The types are mapped to SQLite equivalents.
5
+
6
+ # Type mappings: PostgreSQL type -> SQLite equivalent
7
+ PLUTONIUM_SQLITE_TYPE_ALIASES = {
8
+ jsonb: :json,
9
+ hstore: :json,
10
+ uuid: :string,
11
+ inet: :string,
12
+ cidr: :string,
13
+ macaddr: :string,
14
+ ltree: :string
15
+ }.freeze
2
16
 
3
17
  ActiveSupport.on_load(:active_record) do
4
- if defined?(ActiveRecord::ConnectionAdapters::SQLite3::TableDefinition)
5
- ActiveRecord::ConnectionAdapters::SQLite3::TableDefinition.class_eval do
6
- def jsonb(*args, **options)
7
- json(*args, **options)
18
+ next unless defined?(ActiveRecord::ConnectionAdapters::SQLite3Adapter)
19
+
20
+ # Add methods to TableDefinition for create_table blocks
21
+ ActiveRecord::ConnectionAdapters::SQLite3::TableDefinition.class_eval do
22
+ PLUTONIUM_SQLITE_TYPE_ALIASES.each do |pg_type, sqlite_type|
23
+ define_method(pg_type) do |*args, **options|
24
+ send(sqlite_type, *args, **options)
8
25
  end
26
+ end
27
+ end
9
28
 
10
- def uuid(*args, **options)
11
- string(*args, **options)
29
+ # Override valid_type? to accept PostgreSQL types (instance method)
30
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter.class_eval do
31
+ alias_method :original_valid_type?, :valid_type?
32
+
33
+ def valid_type?(type)
34
+ PLUTONIUM_SQLITE_TYPE_ALIASES.key?(type&.to_sym) || original_valid_type?(type)
35
+ end
36
+ end
37
+
38
+ # Override valid_type? class method for Rails generators (Rails 8.1+)
39
+ if ActiveRecord::ConnectionAdapters::SQLite3Adapter.respond_to?(:valid_type?)
40
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter.singleton_class.class_eval do
41
+ alias_method :original_valid_type?, :valid_type?
42
+
43
+ def valid_type?(type)
44
+ PLUTONIUM_SQLITE_TYPE_ALIASES.key?(type&.to_sym) || original_valid_type?(type)
12
45
  end
13
46
  end
14
47
  end
48
+
49
+ # Override type_to_sql to map PostgreSQL types to SQLite equivalents
50
+ ActiveRecord::ConnectionAdapters::SQLite3Adapter.class_eval do
51
+ alias_method :original_type_to_sql, :type_to_sql
52
+
53
+ def type_to_sql(type, **)
54
+ mapped_type = PLUTONIUM_SQLITE_TYPE_ALIASES[type&.to_sym] || type
55
+ original_type_to_sql(mapped_type, **)
56
+ end
57
+ end
15
58
  end
@@ -44,6 +44,8 @@ Format: `name:type:index_type`
44
44
  | `datetime` | `published_at:datetime` | Date and time |
45
45
  | `time` | `starts_at:time` | Time only |
46
46
  | `json` | `metadata:json` | JSON data |
47
+ | `jsonb` | `settings:jsonb` | JSONB (PostgreSQL) |
48
+ | `uuid` | `external_id:uuid` | UUID field |
47
49
 
48
50
  ### Nullable Fields
49
51
 
@@ -65,6 +67,18 @@ Use `{precision,scale}` syntax for decimal fields:
65
67
  'amount:decimal?{15,2}' # nullable with precision
66
68
  ```
67
69
 
70
+ ### Default Values
71
+
72
+ Use `{default:value}` syntax to set default values:
73
+
74
+ ```bash
75
+ 'status:string{default:draft}' # String default
76
+ 'active:boolean{default:true}' # Boolean default
77
+ 'priority:integer{default:0}' # Integer default
78
+ 'price:decimal{10,2,default:0}' # Decimal with precision and default
79
+ 'category:string?{default:general}' # Nullable with default
80
+ ```
81
+
68
82
  ### Associations
69
83
 
70
84
  ```bash
@@ -75,6 +89,10 @@ company:references # Same as belongs_to
75
89
  # Nullable belongs_to
76
90
  'parent:belongs_to?' # Creates: null: true, optional: true
77
91
 
92
+ # Custom class_name (column author_id -> User model)
93
+ 'author:belongs_to{class_name:User}'
94
+ 'reviewer:belongs_to?{class_name:User}' # Nullable
95
+
78
96
  # Cross-package reference
79
97
  blogging/post:belongs_to
80
98
  ```
@@ -359,9 +377,11 @@ t.belongs_to :user, null: false, foreign_key: {on_delete: :cascade}
359
377
 
360
378
  ### Default Values
361
379
 
380
+ Default values can be set directly in the generator using `{default:value}` syntax. For expressions or complex defaults, edit the migration:
381
+
362
382
  ```ruby
363
383
  t.boolean :is_active, default: true
364
- t.integer :status, default: 0
384
+ t.datetime :published_at, default: -> { "CURRENT_TIMESTAMP" }
365
385
  ```
366
386
 
367
387
  ## Removing Resources
@@ -39,6 +39,9 @@ rails generate pu:res:scaffold Post
39
39
  # With associations
40
40
  rails generate pu:res:scaffold Comment body:text user:belongs_to post:belongs_to
41
41
 
42
+ # With custom class_name (author -> User)
43
+ rails generate pu:res:scaffold Post 'author:belongs_to{class_name:User}'
44
+
42
45
  # Skip model generation for existing models
43
46
  rails generate pu:res:scaffold Post title:string --no-model
44
47
  ```
@@ -57,16 +60,89 @@ rails generate pu:res:scaffold Post title:string --no-model
57
60
  | `datetime` | `published_at:datetime` | `datetime` |
58
61
  | `time` | `starts_at:time` | `time` |
59
62
  | `json` | `metadata:json` | `json` |
63
+ | `jsonb` | `settings:jsonb` | `jsonb` (PostgreSQL) / `json` (SQLite) |
64
+ | `uuid` | `external_id:uuid` | `uuid` (PostgreSQL) / `string` (SQLite) |
60
65
  | `belongs_to` | `user:belongs_to` | `references` |
61
66
  | `references` | `user:references` | `references` |
62
67
  | `rich_text` | `content:rich_text` | Action Text |
63
68
 
69
+ #### PostgreSQL-Specific Types
70
+
71
+ These types work in both PostgreSQL and SQLite (mapped to compatible types):
72
+
73
+ | Type | PostgreSQL | SQLite Equivalent |
74
+ |------|------------|-------------------|
75
+ | `jsonb` | `jsonb` | `json` |
76
+ | `hstore` | `hstore` | `json` |
77
+ | `uuid` | `uuid` | `string` |
78
+ | `inet` | `inet` | `string` |
79
+ | `cidr` | `cidr` | `string` |
80
+ | `macaddr` | `macaddr` | `string` |
81
+ | `ltree` | `ltree` | `string` |
82
+
64
83
  #### Nullable Fields
65
84
 
66
85
  Append `?` to make a field nullable:
67
86
 
68
87
  ```bash
69
- rails generate pu:res:scaffold Post title:string description:text?
88
+ rails generate pu:res:scaffold Post title:string 'description:text?'
89
+ ```
90
+
91
+ #### Default Values
92
+
93
+ Use `{default:value}` syntax to set default values:
94
+
95
+ ```bash
96
+ # String with default
97
+ rails generate pu:res:scaffold Post 'status:string{default:draft}'
98
+
99
+ # Boolean with default (supports true/false/yes/1)
100
+ rails generate pu:res:scaffold Post 'active:boolean{default:true}'
101
+
102
+ # Integer with default
103
+ rails generate pu:res:scaffold Post 'priority:integer{default:0}'
104
+
105
+ # Decimal with precision and default
106
+ rails generate pu:res:scaffold Product 'price:decimal{10,2,default:0}'
107
+
108
+ # Nullable with default
109
+ rails generate pu:res:scaffold Post 'category:string?{default:general}'
110
+
111
+ # JSONB with empty hash default
112
+ rails generate pu:res:scaffold Post 'metadata:jsonb{default:{}}'
113
+
114
+ # JSONB with empty array default
115
+ rails generate pu:res:scaffold Post 'tags:jsonb{default:[]}'
116
+
117
+ # JSONB with object default
118
+ rails generate pu:res:scaffold Post 'settings:jsonb{default:{"theme":"dark"}}'
119
+ ```
120
+
121
+ ::: tip Shell Quoting
122
+ Always quote fields containing `?` or `{}` to prevent shell expansion.
123
+ :::
124
+
125
+ ::: tip JSON Default Values
126
+ Default values are parsed as JSON first. This allows structured defaults like `{}` and `[]` for JSONB fields. If JSON parsing fails, the value is treated as a string (or coerced based on the column type for integers, floats, and booleans).
127
+ :::
128
+
129
+ #### Associations
130
+
131
+ ```bash
132
+ # Required belongs_to
133
+ rails generate pu:res:scaffold Comment user:belongs_to post:belongs_to
134
+
135
+ # Nullable belongs_to
136
+ rails generate pu:res:scaffold Post 'parent:belongs_to?'
137
+
138
+ # Custom class_name (author_id column -> User model)
139
+ rails generate pu:res:scaffold Post 'author:belongs_to{class_name:User}'
140
+
141
+ # Nullable with custom class_name
142
+ rails generate pu:res:scaffold Post 'reviewer:belongs_to?{class_name:User}'
143
+
144
+ # Cross-package reference
145
+ rails generate pu:res:scaffold Comment blogging/post:belongs_to
70
146
  ```
71
147
 
72
148
  #### Money Fields (has_cents)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.39.2)
4
+ plutonium (0.41.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 9.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.39.2)
4
+ plutonium (0.41.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 9.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.39.2)
4
+ plutonium (0.41.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 9.0)
@@ -58,9 +58,14 @@ module Pu
58
58
  end
59
59
 
60
60
  def replace_build_script
61
- gsub_file "package.json",
62
- /"build:css":.*/,
63
- '"build:css": "postcss ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css"'
61
+ package_json = File.read("package.json")
62
+ package = JSON.parse(package_json)
63
+
64
+ package["scripts"] ||= {}
65
+ package["scripts"]["build"] = "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets"
66
+ package["scripts"]["build:css"] = "postcss ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/application.css"
67
+
68
+ File.write("package.json", JSON.pretty_generate(package) + "\n")
64
69
  end
65
70
 
66
71
  def import_styles
@@ -127,8 +127,8 @@ module Pu
127
127
 
128
128
  def add_entity_association
129
129
  inject_into_file entity_model_path,
130
- "has_many :user_invites, class_name: \"Invites::UserInvite\", dependent: :destroy\n ",
131
- before: "# add has_many associations above."
130
+ " has_many :user_invites, class_name: \"Invites::UserInvite\", dependent: :destroy\n",
131
+ before: /^\s*# add has_many associations above\.\n/
132
132
  end
133
133
 
134
134
  def create_entity_interaction
@@ -22,7 +22,7 @@ module Invites
22
22
 
23
23
  def login_path
24
24
  <% if rodauth? -%>
25
- rodauth(:<%= rodauth_config %>).login_path
25
+ rodauth.login_path
26
26
  <% else -%>
27
27
  "/login"
28
28
  <% end -%>
@@ -30,7 +30,7 @@ module Invites
30
30
 
31
31
  def current_user
32
32
  <% if rodauth? -%>
33
- rodauth(:<%= rodauth_config %>).rails_account if rodauth(:<%= rodauth_config %>).logged_in?
33
+ rodauth.rails_account if rodauth.logged_in?
34
34
  <% else -%>
35
35
  # TODO: Implement based on your authentication system
36
36
  nil
@@ -39,8 +39,7 @@ module Invites
39
39
 
40
40
  <% if rodauth? -%>
41
41
  def create_user_for_signup(email, password)
42
- rodauth_instance = rodauth(:<%= rodauth_config %>)
43
- password_hash = rodauth_instance.password_hash(password)
42
+ password_hash = rodauth.password_hash(password)
44
43
 
45
44
  if email.downcase == @invite.email.downcase
46
45
  # Email matches invitation - create verified account directly
@@ -51,18 +50,18 @@ module Invites
51
50
  )
52
51
  else
53
52
  # Different email - normal flow with verification email
54
- rodauth_instance.create_account(login: email, password: password)
53
+ rodauth.create_account(login: email, password: password)
55
54
  <%= user_model %>.find_by(email: email)
56
55
  end
57
56
  end
58
57
 
59
58
  def sign_in_user(user)
60
- rodauth(:<%= rodauth_config %>).account_from_login(user.email)
61
- rodauth(:<%= rodauth_config %>).login("signup")
59
+ rodauth.account_from_login(user.email)
60
+ rodauth.login("signup")
62
61
  end
63
62
 
64
- def rodauth(name = :<%= rodauth_config %>)
65
- request.env["rodauth.#{name}"]
63
+ def rodauth
64
+ request.env["rodauth.<%= rodauth_config %>"]
66
65
  end
67
66
  <% else -%>
68
67
  def create_user_for_signup(email, password)
@@ -43,15 +43,15 @@ module Invites
43
43
  end
44
44
 
45
45
  def current_user
46
- rodauth(:<%= rodauth_config %>).rails_account if rodauth(:<%= rodauth_config %>).logged_in?
46
+ rodauth.rails_account if rodauth.logged_in?
47
47
  end
48
48
 
49
49
  def login_path
50
- rodauth(:<%= rodauth_config %>).login_path
50
+ rodauth.login_path
51
51
  end
52
52
 
53
- def rodauth(name = :<%= rodauth_config %>)
54
- request.env["rodauth.#{name}"]
53
+ def rodauth
54
+ request.env["rodauth.<%= rodauth_config %>"]
55
55
  end
56
56
  <% else -%>
57
57
  def require_authentication
@@ -71,8 +71,24 @@ module PlutoniumGenerators
71
71
  class GeneratedAttribute < Rails::Generators::GeneratedAttribute
72
72
  class << self
73
73
  def parse(model_name, column_definition)
74
+ # Protect content inside {} from being split on colons
75
+ # e.g., "status:string{default:draft}" -> split correctly
76
+ # Handles nested braces like "data:jsonb{default:{}}"
77
+ options_content = nil
78
+ if column_definition.include?("{")
79
+ options_content = extract_braced_content(column_definition)
80
+ if options_content
81
+ start_idx = column_definition.index("{")
82
+ end_idx = start_idx + options_content.length + 2 # +2 for { and }
83
+ column_definition = column_definition[0...start_idx] + "{OPTIONS}" + column_definition[end_idx..]
84
+ end
85
+ end
86
+
74
87
  name, type, index_type = column_definition.split(":")
75
88
 
89
+ # Restore options content
90
+ type = type&.sub("{OPTIONS}", "{#{options_content}}") if options_content
91
+
76
92
  # if user provided "name:index" instead of "name:string:index"
77
93
  # type should be set blank so GeneratedAttribute's constructor
78
94
  # could set it to :string
@@ -114,18 +130,134 @@ module PlutoniumGenerators
114
130
 
115
131
  private
116
132
 
133
+ # Extends Rails' parse_type_and_options to support:
134
+ # - nullable types with ? suffix: 'string?' -> null: true
135
+ # - default values: 'string{default:value}' -> default: "value"
136
+ # - class_name for references: 'belongs_to{class_name:User}' -> class_name: "User"
117
137
  def parse_type_and_options(type)
118
138
  nullable = type&.include?("?")
119
139
  type = type&.sub("?", "") if nullable
120
140
 
141
+ # Extract custom options before calling super
142
+ # Syntax: type{option:value} or type{limit,option:value}
143
+ default_value = nil
144
+ class_name_value = nil
145
+
146
+ if type&.include?("{")
147
+ options_content = extract_braced_content(type)
148
+
149
+ # Extract default:value (supports nested braces like {})
150
+ if options_content&.include?("default:")
151
+ default_value = extract_option_value(options_content, "default")
152
+ type = remove_option_from_type(type, "default", default_value)
153
+ end
154
+
155
+ # Extract class_name:Value
156
+ if options_content&.include?("class_name:")
157
+ class_name_value = extract_option_value(options_content, "class_name")
158
+ type = remove_option_from_type(type, "class_name", class_name_value)
159
+ end
160
+ end
161
+
121
162
  parsed_type, parsed_options = super
122
163
 
123
164
  parsed_options[:null] = nullable ? true : false
165
+ parsed_options[:default] = coerce_default_value(default_value, parsed_type) if default_value
166
+ if class_name_value
167
+ parsed_options[:class_name] = class_name_value
168
+ parsed_options[:to_table] = class_name_value.underscore.tr("/", "_").pluralize.to_sym
169
+ end
124
170
 
125
171
  [parsed_type, parsed_options]
126
172
  end
127
173
 
128
- private
174
+ # Extracts content inside outermost braces, respecting nested braces
175
+ # e.g., "jsonb{default:{}}" -> "default:{}"
176
+ def extract_braced_content(str)
177
+ start_idx = str.index("{")
178
+ return nil unless start_idx
179
+
180
+ depth = 0
181
+ (start_idx...str.length).each do |i|
182
+ case str[i]
183
+ when "{"
184
+ depth += 1
185
+ when "}"
186
+ depth -= 1
187
+ return str[(start_idx + 1)...i] if depth == 0
188
+ end
189
+ end
190
+ nil
191
+ end
192
+
193
+ # Extracts option value, handling nested braces
194
+ # e.g., from "default:{},other:val" extracts "{}" for "default"
195
+ def extract_option_value(content, option_name)
196
+ prefix = "#{option_name}:"
197
+ start_idx = content.index(prefix)
198
+ return nil unless start_idx
199
+
200
+ value_start = start_idx + prefix.length
201
+ return nil if value_start >= content.length
202
+
203
+ # Check if value starts with a brace (nested structure)
204
+ if content[value_start] == "{"
205
+ depth = 0
206
+ (value_start...content.length).each do |i|
207
+ case content[i]
208
+ when "{"
209
+ depth += 1
210
+ when "}"
211
+ depth -= 1
212
+ return content[value_start..i] if depth == 0
213
+ end
214
+ end
215
+ content[value_start..]
216
+ else
217
+ # Simple value - read until comma or end
218
+ end_idx = content.index(",", value_start) || content.length
219
+ content[value_start...end_idx]
220
+ end
221
+ end
222
+
223
+ def remove_option_from_type(type, option_name, option_value)
224
+ escaped_value = Regexp.escape(option_value)
225
+ options_content = extract_braced_content(type)
226
+ return type unless options_content
227
+
228
+ cleaned = options_content
229
+ .gsub(/,?\s*#{option_name}:#{escaped_value}/, "")
230
+ .gsub(/#{option_name}:#{escaped_value},?\s*/, "")
231
+
232
+ prefix = type[0...type.index("{")]
233
+ cleaned.empty? ? prefix : "#{prefix}{#{cleaned}}"
234
+ end
235
+
236
+ # Coerces default value using JSON parsing for structured types,
237
+ # with type-based fallback for primitives
238
+ def coerce_default_value(value, type)
239
+ # Try JSON for structured types (hashes, arrays)
240
+ parsed = JSON.parse(value)
241
+ return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
242
+
243
+ # For primitives, use type-based coercion
244
+ coerce_primitive_value(value, type)
245
+ rescue JSON::ParserError
246
+ coerce_primitive_value(value, type)
247
+ end
248
+
249
+ def coerce_primitive_value(value, type)
250
+ case type&.to_s
251
+ when "integer"
252
+ value.to_i
253
+ when "float", "decimal"
254
+ value.to_f
255
+ when "boolean"
256
+ %w[true 1 yes].include?(value.downcase)
257
+ else
258
+ value
259
+ end
260
+ end
129
261
 
130
262
  def find_shared_namespace(model1, model2, separator: "::")
131
263
  parts1 = model1.underscore.split(separator)
@@ -12,13 +12,20 @@ module Pu
12
12
 
13
13
  desc(
14
14
  "Create a connection between a resource and a portal\n\n" \
15
- "e.g. rails g pu:res:conn todo --dest=dashboard_portal\n" \
16
- " rails g pu:res:conn profile --dest=customer_portal --singular"
15
+ "e.g. rails g pu:res:conn Todo --dest=dashboard_portal\n" \
16
+ " rails g pu:res:conn Profile --dest=customer_portal --singular\n" \
17
+ " rails g pu:res:conn Post --dest=admin_portal --policy --definition"
17
18
  )
18
19
 
19
20
  class_option :singular, type: :boolean, default: false,
20
21
  desc: "Register the resource as a singular resource (e.g., profile)"
21
22
 
23
+ class_option :policy, type: :boolean, default: false,
24
+ desc: "Create portal-specific policy even if base policy exists"
25
+
26
+ class_option :definition, type: :boolean, default: false,
27
+ desc: "Create portal-specific definition even if base definition exists"
28
+
22
29
  def start
23
30
  selected_resources = resources_selection
24
31
  @app_namespace = portal_option(:dest, prompt: "Select destination portal").camelize
@@ -33,12 +40,12 @@ module Pu
33
40
  indent("register_resource ::#{resource}#{singular_option}\n", 2),
34
41
  after: /.*Rails\.application\.routes\.draw do.*\n/
35
42
  else
36
- unless expected_parent_policy
43
+ if options[:policy] || !expected_parent_policy
37
44
  template "app/policies/resource_policy.rb",
38
45
  "packages/#{package_namespace}/app/policies/#{package_namespace}/#{resource.underscore}_policy.rb"
39
46
  end
40
47
 
41
- unless expected_parent_definition
48
+ if options[:definition] || !expected_parent_definition
42
49
  template "app/definitions/resource_definition.rb",
43
50
  "packages/#{package_namespace}/app/definitions/#{package_namespace}/#{resource.underscore}_definition.rb"
44
51
  end
@@ -5,8 +5,6 @@ module Pu
5
5
  def self.included(base)
6
6
  base.send :include, Configuration
7
7
 
8
- base.send :class_option, :primary, type: :boolean,
9
- desc: "[CONFIG] generated account is primary"
10
8
  base.send :class_option, :argon2, type: :boolean, default: false,
11
9
  desc: "[CONFIG] use Argon2 for password hashing"
12
10
  base.send :class_option, :mails, type: :boolean, default: true, desc: "[CONFIG] setup mails"
@@ -69,7 +67,7 @@ module Pu
69
67
  # Creates a hash of options to pass down options to an invoked sub generator
70
68
  def invoke_options
71
69
  # These are custom options we want to track.
72
- extra_options = %i[primary argon2 mails kitchen_sink defaults]
70
+ extra_options = %i[argon2 mails kitchen_sink defaults]
73
71
  # Append them to all the available options from our configuration
74
72
  valid_options = configuration.keys.map(&:to_sym).concat extra_options
75
73
  # Index map the list with the selection value
@@ -101,7 +99,7 @@ module Pu
101
99
  end
102
100
 
103
101
  def primary?
104
- options[:primary]
102
+ false # Primary (unnamed) accounts are not supported - all accounts must be named
105
103
  end
106
104
 
107
105
  def argon2?
@@ -146,7 +146,7 @@ module Pu
146
146
  end
147
147
 
148
148
  def default_primary_key_type
149
- if ActiveRecord.version >= Gem::Version.new("5.1")
149
+ if ActiveRecord.version >= ::Gem::Version.new("5.1")
150
150
  :bigint
151
151
  else
152
152
  :integer
@@ -156,7 +156,7 @@ module Pu
156
156
  # Active Record 7+ sets default precision to 6 for timestamp columns,
157
157
  # so we need to ensure we match this when setting the default value.
158
158
  def current_timestamp
159
- if ActiveRecord.version >= Gem::Version.new("7.0") && ["mysql2", "trilogy"].include?(activerecord_adapter) && ActiveRecord::Base.connection.supports_datetime_with_precision?
159
+ if ActiveRecord.version >= ::Gem::Version.new("7.0") && ["mysql2", "trilogy"].include?(activerecord_adapter) && ActiveRecord::Base.connection.supports_datetime_with_precision?
160
160
  "CURRENT_TIMESTAMP(6)"
161
161
  else
162
162
  "CURRENT_TIMESTAMP"
@@ -108,7 +108,7 @@ module Pu
108
108
  has_many :#{membership_table_name}, dependent: :destroy
109
109
  has_many :#{normalized_user_name.pluralize}, through: :#{membership_table_name}
110
110
  RUBY
111
- insert_into_file entity_model_path, indent(associations, 2), before: /# add has_many associations above\.\n/
111
+ inject_into_file entity_model_path, associations, before: /^\s*# add has_many associations above\.\n/
112
112
  end
113
113
 
114
114
  def add_association_to_user_model
@@ -120,7 +120,7 @@ module Pu
120
120
  has_many :#{membership_table_name}, dependent: :destroy
121
121
  has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}
122
122
  RUBY
123
- insert_into_file user_model_path, indent(associations, 2), before: /# add has_many associations above\.\n/
123
+ inject_into_file user_model_path, associations, before: /^\s*# add has_many associations above\.\n/
124
124
  end
125
125
 
126
126
  def find_migration_file
@@ -154,10 +154,10 @@ module Plutonium
154
154
  end
155
155
  end
156
156
  rescue ActiveRecord::RecordInvalid => e
157
- if e.record.is_a?(invite_class)
158
- flash.now[:alert] = e.record.errors.full_messages.join(", ")
157
+ flash.now[:alert] = if e.record.is_a?(invite_class)
158
+ e.record.errors.full_messages.join(", ")
159
159
  else
160
- flash.now[:alert] = "Failed to create account: #{e.record.errors.full_messages.join(", ")}"
160
+ "Failed to create account: #{e.record.errors.full_messages.join(", ")}"
161
161
  end
162
162
  render :signup
163
163
  rescue => e
@@ -33,7 +33,7 @@ module Plutonium
33
33
  @add_url ||= begin
34
34
  return unless @skip_authorization || allowed_to?(:create?, association_reflection.klass)
35
35
 
36
- url = @add_action || resource_url_for(association_reflection.klass, action: :new, parent: nil)
36
+ url = @add_action || (registered_resources.include?(association_reflection.klass) && resource_url_for(association_reflection.klass, action: :new, parent: nil))
37
37
  return unless url
38
38
 
39
39
  uri = URI(url)
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.40.0"
2
+ VERSION = "0.41.1"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.40.0",
3
+ "version": "0.41.1",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.40.0
4
+ version: 0.41.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-04 00:00:00.000000000 Z
11
+ date: 2026-02-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk