zero-rails-adapter 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15ffa084c77f3af139ecfc0e284d2b2d41265357ec3b3edafda78d5c7378c8b8
4
- data.tar.gz: a6aaafe3bb4cca20cfd6544ad6fd63f49bca923ebd225edb0811069a31c3c778
3
+ metadata.gz: 87460dcc3c79305e4ca81c9bd30800866a2a9c97709298955cdf16334723d442
4
+ data.tar.gz: 9c76ea660812fb14a1fa52fc148e00e973da5b670635dcd85242d781b93f0958
5
5
  SHA512:
6
- metadata.gz: cdb046e65aa33933cdb5e33ede0f484275e7734ad637cbe87d298846a5636d79c4a1b5188d390551224c9d660c7dc8ab3bd4a48dfd54571f61c54520a895a1f0
7
- data.tar.gz: b3b4149d75a0519b307d7464fe48da60c77eca56a71283791f858151d518f164245e3027310347c342d57d18f03729afb9d2fc66a5a0e20d947ea2807d1d5195
6
+ metadata.gz: 13941576c897585d1253128412c7a3b062c273e407b1430e51cbae1f0dd9fc4a89d24b5fba7b8f5398ff610d72b64a38b286293af8146f6270bc5db27394a12c
7
+ data.tar.gz: e4785424bc04062e227084591faf341260d67177bf73c588494778b4037e25b9642fc6e444718554c90bdf371928ab71f7c01952c1dc4d3627d73c14f5b83ad5
data/README.md CHANGED
@@ -25,7 +25,7 @@ mass-assignment policies are all configurable callable interfaces.
25
25
  - Ruby 4.0 or newer
26
26
  - Rails 8.0 or newer
27
27
  - PostgreSQL, the upstream database supported by Zero
28
- - Current `@rocicorp/zero` and `zero-cache` releases
28
+ - `@rocicorp/zero` and `zero-cache` from the same supported release
29
29
 
30
30
  The Zero-managed `schema.clients` and `schema.mutations` tables and the
31
31
  application tables must use the same database connection. In a Rails
@@ -73,8 +73,64 @@ The adapter uses Zero's validated `schema` parameter to build schema-qualified
73
73
  Active Record classes for those tables, then writes application data and the
74
74
  LMID in the same transaction.
75
75
 
76
+ ## Explicit Publication Schema
77
+
78
+ The adapter fails closed. No Active Record model or column is published, added
79
+ to generated TypeScript, or exposed to generic CRUD by default.
80
+
81
+ Declare both the tables and columns that may be replicated:
82
+
83
+ ```ruby
84
+ ZeroRailsAdapter.configure do |config|
85
+ config.published_schema = lambda do
86
+ {
87
+ Article => %w[id title body author_id created_at updated_at],
88
+ Comment => %w[id article_id body author_id created_at updated_at]
89
+ }
90
+ end
91
+ end
92
+ ```
93
+
94
+ The callable is evaluated when code is generated, so it remains safe across
95
+ Rails development reloads. Primary key columns must be present. Unknown
96
+ columns, known credential columns such as `password_hash` and `token_digest`,
97
+ Active Storage and Action Mailbox internal tables, and PostgreSQL types that
98
+ Zero cannot replicate are rejected.
99
+
100
+ By default, a model's Zero key is its Active Record primary key. Applications
101
+ may use a separate stable synchronization key:
102
+
103
+ ```ruby
104
+ config.zero_key = lambda do |model|
105
+ model == Article ? "sync_id" : model.primary_key
106
+ end
107
+ ```
108
+
109
+ A separate Zero key must be included in `published_schema` and backed by an
110
+ exact unique, non-null database index. Composite Zero keys may be returned as
111
+ an array. The database primary key must still be published because PostgreSQL
112
+ uses it as the table's replica identity; it is not silently replaced by the
113
+ Zero key.
114
+
115
+ Generate a reviewable, column-limited PostgreSQL publication:
116
+
117
+ ```sh
118
+ bin/rails generate zero_rails_adapter:publication zero_data
119
+ ```
120
+
121
+ This writes `db/zero_publication.sql`; it does not execute DDL automatically.
122
+ Apply the SQL through the application's normal migration or operations process,
123
+ then configure:
124
+
125
+ ```sh
126
+ ZERO_APP_PUBLICATIONS=zero_data
127
+ ```
128
+
76
129
  ## Generic Active Record CRUD
77
130
 
131
+ Generic CRUD is a separate, opt-in capability. Publishing a model never makes
132
+ it writable.
133
+
78
134
  This client-side mutation:
79
135
 
80
136
  ```ts
@@ -92,8 +148,9 @@ Article.create!(id: "...", title: "Rails and Zero")
92
148
  ```
93
149
 
94
150
  For `update` and `destroy`, the adapter first loads the record using the
95
- model's primary key, including composite primary keys, and then calls
96
- `update!` or `destroy!`. The bang methods are intentional: a validation,
151
+ configured Zero key, including composite keys, and then calls `update!` or
152
+ `destroy!`. Neither the Zero key nor the Active Record primary key can be
153
+ changed by generic update. The bang methods are intentional: a validation,
97
154
  callback, or database-constraint failure rolls back the complete business
98
155
  transaction and produces a structured Zero application error.
99
156
 
@@ -101,14 +158,16 @@ Configure the models exposed to generic CRUD explicitly:
101
158
 
102
159
  ```ruby
103
160
  ZeroRailsAdapter.configure do |config|
104
- config.model_provider = -> { [Article, Comment, Project] }
161
+ config.crud_model_provider = -> { [Article, Comment] }
105
162
  end
106
163
  ```
107
164
 
108
- `model_provider` is both the CRUD allowlist and the source used by the
109
- TypeScript generator. By default, the adapter eager-loads the Rails application
110
- and returns every named, non-abstract Active Record model outside the adapter
111
- itself. An explicit allowlist is recommended in production.
165
+ Models absent from `crud_model_provider` cannot be resolved by generic CRUD.
166
+ The default provider returns an empty list, and the default `crud_authorizer`
167
+ also rejects every operation. Applications must opt into both model resolution
168
+ and authorization. Applications with aggregate operations, tenant-scoped
169
+ commands, soft deletion, or other domain behavior should leave the provider
170
+ empty and use custom mutators.
112
171
 
113
172
  If a table cannot be resolved through Rails naming conventions, replace the
114
173
  resolver:
@@ -128,14 +187,13 @@ hand:
128
187
  bin/rails generate zero_rails_adapter:typescript app/javascript/zero
129
188
  ```
130
189
 
131
- The generator reflects on the models returned by `model_provider` in the Rails
132
- runtime and writes:
190
+ The generator reflects on `published_schema` in the Rails runtime and writes:
133
191
 
134
- - `schema.ts`, containing tables, columns, nullability, primary keys, and
192
+ - `schema.ts`, containing tables, columns, nullability, Zero keys, and
135
193
  safely inferred `belongs_to`, `has_one`, and `has_many` relationships.
136
- - `mutators.ts`, containing `create`, `update`, and `destroy` for every table,
137
- using Zero's current `defineMutator` / `defineMutators` API and Zod argument
138
- schemas.
194
+ - `mutators.ts`, containing `create`, `update`, and `destroy` only for models
195
+ returned by `crud_model_provider`, using Zero's current `defineMutator` /
196
+ `defineMutators` API and Zod argument schemas.
139
197
 
140
198
  Run the generator again after changing Rails migrations or model associations.
141
199
  The output can live in Rails' JavaScript directory or be written directly into
@@ -145,6 +203,48 @@ an adjacent Next.js application:
145
203
  bin/rails generate zero_rails_adapter:typescript ../web/src/zero
146
204
  ```
147
205
 
206
+ Rails cannot safely infer every Zero relationship. Add delegated-type,
207
+ polymorphic, custom, or through relationships explicitly:
208
+
209
+ ```ruby
210
+ config.relationship_provider = lambda do
211
+ [
212
+ {
213
+ source: Recording,
214
+ name: :task,
215
+ kind: :one,
216
+ source_fields: %w[recordable_id],
217
+ destination: Task,
218
+ destination_fields: %w[id]
219
+ },
220
+ {
221
+ source: Article,
222
+ name: :labels,
223
+ kind: :many,
224
+ through: [
225
+ {
226
+ source_fields: %w[id],
227
+ destination: ArticleLabel,
228
+ destination_fields: %w[article_id]
229
+ },
230
+ {
231
+ source_fields: %w[label_id],
232
+ destination: Label,
233
+ destination_fields: %w[id]
234
+ }
235
+ ]
236
+ }
237
+ ]
238
+ end
239
+ ```
240
+
241
+ Every model and field in a manual relationship must be published. Zero
242
+ supports at most two relationship hops. A polymorphic or delegated-type
243
+ relationship also needs an application invariant or a published discriminator
244
+ or safe mirror column that prevents IDs belonging to another type from
245
+ matching; the adapter does not guess that predicate. A manual definition with
246
+ the same source and name replaces an inferred relationship.
247
+
148
248
  The default mappings follow Zero's PostgreSQL type conventions:
149
249
 
150
250
  - string/text/uuid → `string()`
@@ -152,10 +252,13 @@ The default mappings follow Zero's PostgreSQL type conventions:
152
252
  - boolean → `boolean()`
153
253
  - date/time/datetime/timestamp → `number()`
154
254
  - json/jsonb → `json()`
155
- - Active Record enum → `enumeration<...>()`
255
+ - integer-backed Active Record enum → `number()`
256
+ - string-backed Active Record enum → `string()`
257
+ - PostgreSQL native enum → `enumeration<...>()`
156
258
 
157
259
  Nullable columns use `.optional()`. Rails timestamps use `Date.now()` for the
158
- optimistic client write and remain managed normally by Rails on the server.
260
+ optimistic client write and are Unix epoch milliseconds in Zero. They remain
261
+ managed normally by Rails on the server.
159
262
  The generator raises a descriptive error for a column that cannot be mapped
160
263
  reliably instead of emitting an incorrect type.
161
264
 
@@ -333,6 +436,21 @@ Run the complete contract suite against a dedicated PostgreSQL test database:
333
436
  DATABASE_URL=postgres://localhost/zero_rails_adapter_test bundle exec rake test
334
437
  ```
335
438
 
439
+ The repository also locks `@rocicorp/zero` and the `zero-cache` CLI to the
440
+ exact same `1.8.0` package in `test/contract/package-lock.json`. Run the full
441
+ wire and replication contract with Node 24+ and Docker:
442
+
443
+ ```sh
444
+ bundle exec rake contract
445
+ ```
446
+
447
+ That task compiles the generated TypeScript, starts PostgreSQL with logical
448
+ replication, starts the real zero-cache and Rails mutation endpoint, then uses
449
+ a Zero client to mutate and query replicated rows. The generated fixture
450
+ includes a separate Zero key and a two-hop relationship. The task also verifies
451
+ LMID advancement, duplicate-mutation handling, failure skipping, and
452
+ `_zero_cleanupResults`. The contract runs as its own CI job.
453
+
336
454
  See [`examples/nextjs`](examples/nextjs) for a Next.js integration fixture.
337
455
 
338
456
  ## References
@@ -340,5 +458,6 @@ See [`examples/nextjs`](examples/nextjs) for a Next.js integration fixture.
340
458
  - [Zero custom mutators](https://zero.rocicorp.dev/docs/mutators)
341
459
  - [Zero schema](https://zero.rocicorp.dev/docs/schema)
342
460
  - [Zero PostgreSQL support](https://zero.rocicorp.dev/docs/postgres-support)
461
+ - [Zero 1.8 release notes](https://zero.rocicorp.dev/docs/release-notes/1.8)
343
462
  - [Zero `process-mutations.ts`](https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/process-mutations.ts)
344
463
  - [Server implementation plan](https://jeremykreutzbender.com/blog/server-implementation-plan-rocicorp-zero-custom-mutators)
@@ -25,5 +25,7 @@ ZERO_MUTATE_API_KEY=development-secret
25
25
  ```
26
26
 
27
27
  The Rails application must mount the Engine at `/zero`, use the generated API
28
- key verifier, and expose `Book` through `config.model_provider`. No Ruby
29
- mutator class is needed for `books.create/update/destroy`.
28
+ key verifier, publish explicit `Book` columns through
29
+ `config.published_schema`, and opt `Book` into
30
+ `config.crud_model_provider`. No Ruby mutator class is needed for
31
+ `books.create/update/destroy`.
@@ -26,10 +26,31 @@ ZeroRailsAdapter.configure do |config|
26
26
  # Raise ZeroRailsAdapter::ForbiddenError to reject a mutation.
27
27
  # config.authorizer = ->(context, mutation) { YourPolicy.authorize!(context, mutation) }
28
28
 
29
- # Generic CRUD resolves "articles.create", "articles.update", and
30
- # "articles.destroy" against this allowlist. The default is every named,
31
- # non-abstract Active Record model; an explicit list is recommended.
32
- # config.model_provider = -> { [Article, Comment] }
29
+ # Fail-closed table and column allowlist used by schema.ts and publication
30
+ # SQL generation. No tables or columns are published by default.
31
+ # config.published_schema = lambda do
32
+ # {
33
+ # Article => %w[id title body author_id created_at updated_at],
34
+ # Comment => %w[id article_id body author_id created_at updated_at]
35
+ # }
36
+ # end
37
+
38
+ # Generic CRUD is independently opt-in. Publishing a model does not make it
39
+ # writable. The default crud_authorizer below also rejects every operation.
40
+ # Keep this empty when writes require domain-specific mutators.
41
+ # config.crud_model_provider = -> { [Article, Comment] }
42
+
43
+ # Zero uses this stable key in generated schemas and CRUD update/destroy
44
+ # lookups. A non-Active Record key must be published and backed by an exact
45
+ # unique, non-null database index. Composite keys may return an array.
46
+ # config.zero_key = lambda do |model|
47
+ # model == Article ? "sync_id" : model.primary_key
48
+ # end
49
+
50
+ # Add relationships that Rails reflection cannot express safely, including
51
+ # polymorphic, delegated-type, and through relationships. Definitions may
52
+ # contain one direct hop or a maximum of two entries under `through`.
53
+ # config.relationship_provider = -> { [] }
33
54
 
34
55
  # Authorize generic CRUD independently of Devise/JWT/Pundit/etc. target is
35
56
  # the model class for create, and the loaded record for update/destroy.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "zero_rails_adapter"
5
+
6
+ module ZeroRailsAdapter
7
+ module Generators
8
+ class PublicationGenerator < Rails::Generators::Base
9
+ argument :publication_name,
10
+ type: :string,
11
+ default: ZeroRailsAdapter::PostgreSQL::PublicationGenerator::DEFAULT_NAME
12
+
13
+ def create_publication_sql
14
+ sql = ZeroRailsAdapter::PostgreSQL::PublicationGenerator.new(
15
+ name: publication_name
16
+ ).sql
17
+ create_file "db/zero_publication.sql", sql
18
+ end
19
+ end
20
+ end
21
+ end
@@ -5,18 +5,22 @@ module ZeroRailsAdapter
5
5
  attr_accessor :authenticator, :request_verifier, :authorizer, :logger,
6
6
  :transaction_class, :storage_provider, :crud_authorizer,
7
7
  :writable_attributes, :generated_attributes, :model_resolver,
8
- :model_provider
8
+ :published_schema, :crud_model_provider, :zero_key,
9
+ :relationship_provider
9
10
 
10
11
  def initialize
11
12
  @authenticator = ->(_request) { Identity.new }
12
13
  @request_verifier = ->(_request) { true }
13
14
  @authorizer = ->(_context, _mutation) { true }
14
- @crud_authorizer = ->(_context, _action, _target, _attributes) { true }
15
+ @crud_authorizer = ->(_context, _action, _target, _attributes) { false }
15
16
  @logger = defined?(Rails) ? Rails.logger : nil
16
17
  @transaction_class = ActiveRecord::Base
17
- @model_provider = -> { default_models }
18
+ @published_schema = -> { {} }
19
+ @crud_model_provider = -> { [] }
20
+ @zero_key = ->(model) { model.primary_key }
21
+ @relationship_provider = -> { [] }
18
22
  @model_resolver = lambda do |resource|
19
- allowed_models = Array(model_provider.call).select do |model|
23
+ allowed_models = Array(crud_model_provider.call).select do |model|
20
24
  active_record_model?(model)
21
25
  end
22
26
  candidate = resource.to_s.classify.safe_constantize
@@ -32,16 +36,6 @@ module ZeroRailsAdapter
32
36
 
33
37
  private
34
38
 
35
- def default_models
36
- application = Rails.application if defined?(Rails) && Rails.respond_to?(:application)
37
- application&.eager_load!
38
- ActiveRecord::Base.descendants.select do |model|
39
- active_record_model?(model) &&
40
- model.name.present? &&
41
- !model.name.start_with?("ZeroRailsAdapter::")
42
- end
43
- end
44
-
45
39
  def default_writable_attributes(model)
46
40
  model.column_names -
47
41
  model.readonly_attributes.to_a -
@@ -68,7 +68,8 @@ module ZeroRailsAdapter
68
68
  def update(model, attributes)
69
69
  record = find_record!(model, attributes)
70
70
  authorize!(:update, record, attributes)
71
- changes = writable(model, :update, attributes).except(*primary_keys(model))
71
+ immutable_keys = zero_keys(model) + Array(model.primary_key).compact.map(&:to_s)
72
+ changes = writable(model, :update, attributes).except(*immutable_keys)
72
73
  record.update!(changes)
73
74
  nil
74
75
  end
@@ -81,21 +82,26 @@ module ZeroRailsAdapter
81
82
  end
82
83
 
83
84
  def find_record!(model, attributes)
84
- keys = primary_keys(model)
85
+ keys = zero_keys(model)
85
86
  values = attributes.slice(*keys)
86
87
  missing = keys - values.keys
87
88
  if missing.any?
88
89
  raise ValidationError.new(
89
- "Missing primary key attributes: #{missing.join(', ')}",
90
- details: {"primaryKey" => missing}
90
+ "Missing Zero key attributes: #{missing.join(', ')}",
91
+ details: {"zeroKey" => missing}
91
92
  )
92
93
  end
93
94
 
94
95
  model.find_by!(values)
95
96
  end
96
97
 
97
- def primary_keys(model)
98
- Array(model.primary_key).map(&:to_s)
98
+ def zero_keys(model)
99
+ keys = Array(
100
+ ZeroRailsAdapter.configuration.zero_key.call(model)
101
+ ).compact.map(&:to_s)
102
+ return keys if keys.any?
103
+
104
+ raise ValidationError, "#{model.name} must define at least one Zero key"
99
105
  end
100
106
 
101
107
  def authorize!(action, target, attributes)
@@ -17,6 +17,8 @@ module ZeroRailsAdapter
17
17
  class UnauthorizedError < Error; end
18
18
  class UnknownMutatorError < ApplicationError; end
19
19
  class UnsupportedColumnTypeError < Error; end
20
+ class UnsafePublicationError < Error; end
21
+ class InvalidRelationshipError < Error; end
20
22
 
21
23
  class ProtocolError < Error
22
24
  attr_reader :mutation_ids
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroRailsAdapter
4
+ module PostgreSQL
5
+ class PublicationGenerator
6
+ DEFAULT_NAME = "zero_data"
7
+
8
+ def initialize(name: DEFAULT_NAME, published_schema: nil)
9
+ @name = name.to_s
10
+ @published_schema = PublishedSchema.new(
11
+ published_schema || ZeroRailsAdapter.configuration.published_schema.call,
12
+ zero_key: ZeroRailsAdapter.configuration.zero_key
13
+ )
14
+ end
15
+
16
+ def sql
17
+ entries = @published_schema.models.map do |model|
18
+ connection = model.connection
19
+ quoted_columns = @published_schema.column_names_for(model).map do |column|
20
+ connection.quote_column_name(column)
21
+ end
22
+
23
+ "#{connection.quote_table_name(model.table_name)} (#{quoted_columns.join(', ')})"
24
+ end
25
+
26
+ raise UnsafePublicationError, "Published schema must include at least one table" if entries.empty?
27
+
28
+ connection = @published_schema.models.first.connection
29
+ <<~SQL
30
+ CREATE PUBLICATION #{connection.quote_column_name(@name)} FOR TABLE
31
+ #{entries.join(",\n ")};
32
+ SQL
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroRailsAdapter
4
+ class PublishedSchema
5
+ SUPPORTED_COLUMN_TYPES = %i[
6
+ bigint boolean date datetime decimal enum float integer json jsonb
7
+ string text time timestamp uuid
8
+ ].freeze
9
+ FORBIDDEN_COLUMN_NAMES = %w[
10
+ access_token api_key api_key_digest device_token key_digest
11
+ password password_digest password_hash refresh_token secret secret_key
12
+ token token_digest
13
+ ].freeze
14
+ FORBIDDEN_TABLE_PREFIXES = %w[
15
+ action_mailbox_ active_storage_
16
+ ].freeze
17
+ FORBIDDEN_TABLE_NAMES = %w[
18
+ user_login_change_keys user_lockouts user_password_reset_keys
19
+ user_previous_password_hashes user_recovery_codes user_remember_keys
20
+ user_verification_keys
21
+ ].freeze
22
+
23
+ attr_reader :models
24
+
25
+ def initialize(mapping, zero_key: ZeroRailsAdapter.configuration.zero_key)
26
+ value = mapping.respond_to?(:call) ? mapping.call : mapping
27
+ unless value.respond_to?(:to_h)
28
+ raise UnsafePublicationError,
29
+ "Published schema must be a model-to-columns mapping"
30
+ end
31
+
32
+ @zero_key = zero_key
33
+ @columns_by_model = value.to_h.each_with_object({}) do |(model, names), result|
34
+ validate_model!(model)
35
+ result[model] = validate_columns!(model, names)
36
+ end
37
+ @models = @columns_by_model.keys.freeze
38
+ end
39
+
40
+ def empty?
41
+ models.empty?
42
+ end
43
+
44
+ def column_names_for(model)
45
+ columns_for(model).map(&:name)
46
+ end
47
+
48
+ def columns_for(model)
49
+ @columns_by_model.fetch(model)
50
+ end
51
+
52
+ def zero_keys_for(model)
53
+ keys = Array(@zero_key.call(model)).compact.map(&:to_s)
54
+ return keys.freeze if keys.any?
55
+
56
+ label = model.name.presence || model.table_name
57
+ raise UnsafePublicationError, "#{label} must define at least one Zero key"
58
+ end
59
+
60
+ private
61
+
62
+ def validate_model!(model)
63
+ unless model.is_a?(Class) &&
64
+ model < ActiveRecord::Base &&
65
+ !model.abstract_class?
66
+ raise UnsafePublicationError,
67
+ "#{model.inspect} is not an Active Record model"
68
+ end
69
+
70
+ if FORBIDDEN_TABLE_PREFIXES.any? { |prefix| model.table_name.start_with?(prefix) }
71
+ raise UnsafePublicationError,
72
+ "#{model.table_name} is an internal framework table"
73
+ end
74
+
75
+ if FORBIDDEN_TABLE_NAMES.include?(model.table_name)
76
+ raise UnsafePublicationError,
77
+ "#{model.table_name} is an authentication table"
78
+ end
79
+ end
80
+
81
+ def validate_columns!(model, names)
82
+ names = Array(names).map(&:to_s).uniq
83
+ label = model.name.presence || model.table_name
84
+ raise UnsafePublicationError, "#{label} must publish at least one column" if names.empty?
85
+
86
+ unknown = names - model.column_names
87
+ if unknown.any?
88
+ raise UnsafePublicationError, "#{label}.#{unknown.first} does not exist"
89
+ end
90
+
91
+ forbidden = names.find { |name| FORBIDDEN_COLUMN_NAMES.include?(name) }
92
+ if forbidden
93
+ raise UnsafePublicationError, "#{label}.#{forbidden} is forbidden"
94
+ end
95
+
96
+ columns = names.map { |name| model.columns_hash.fetch(name) }
97
+ unsupported = columns.find do |column|
98
+ !SUPPORTED_COLUMN_TYPES.include?(column.type.to_sym)
99
+ end
100
+ if unsupported
101
+ raise UnsafePublicationError,
102
+ "#{label}.#{unsupported.name} uses unsupported PostgreSQL type " \
103
+ "#{unsupported.sql_type}"
104
+ end
105
+
106
+ missing_keys = Array(model.primary_key).compact.map(&:to_s) - names
107
+ if missing_keys.any?
108
+ key_label = missing_keys.one? ? "column" : "columns"
109
+ raise UnsafePublicationError,
110
+ "#{label} publication must include primary key #{key_label} " \
111
+ "#{missing_keys.join(', ')}"
112
+ end
113
+
114
+ validate_zero_key!(model, names, label)
115
+ columns.freeze
116
+ end
117
+
118
+ def validate_zero_key!(model, published_names, label)
119
+ keys = zero_keys_for(model)
120
+ missing_keys = keys - published_names
121
+ if missing_keys.any?
122
+ key_label = missing_keys.one? ? "column" : "columns"
123
+ raise UnsafePublicationError,
124
+ "#{label} publication must include Zero key #{key_label} " \
125
+ "#{missing_keys.join(', ')}"
126
+ end
127
+
128
+ active_record_keys = Array(model.primary_key).compact.map(&:to_s)
129
+ return if keys == active_record_keys
130
+
131
+ columns = keys.map { |key| model.columns_hash.fetch(key) }
132
+ unique_index = model.connection.indexes(model.table_name).any? do |index|
133
+ index.unique &&
134
+ index.where.blank? &&
135
+ Array(index.columns).map(&:to_s) == keys
136
+ end
137
+ return if columns.none?(&:null) && unique_index
138
+
139
+ raise UnsafePublicationError,
140
+ "#{label} Zero key #{keys.join(', ')} must be backed by a unique, non-null index"
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroRailsAdapter
4
+ class Relationship
5
+ Hop = Struct.new(
6
+ :source_fields,
7
+ :destination,
8
+ :destination_fields,
9
+ keyword_init: true
10
+ )
11
+
12
+ attr_reader :source, :name, :kind, :hops
13
+
14
+ def self.from_definition(definition, published_schema:)
15
+ unless definition.respond_to?(:to_h)
16
+ raise InvalidRelationshipError, "Relationship definition must be an object"
17
+ end
18
+
19
+ attributes = definition.to_h.symbolize_keys
20
+ hop_definitions = if attributes.key?(:through)
21
+ Array(attributes[:through])
22
+ else
23
+ [{
24
+ source_fields: attributes[:source_fields],
25
+ destination: attributes[:destination],
26
+ destination_fields: attributes[:destination_fields]
27
+ }]
28
+ end
29
+
30
+ new(
31
+ source: attributes[:source],
32
+ name: attributes[:name],
33
+ kind: attributes[:kind],
34
+ hops: hop_definitions,
35
+ published_schema:
36
+ )
37
+ end
38
+
39
+ def initialize(source:, name:, kind:, hops:, published_schema:)
40
+ @source = source
41
+ @name = name.to_s
42
+ @kind = kind.to_s
43
+ @published_schema = published_schema
44
+
45
+ validate_header!
46
+ @hops = validate_hops!(hops).freeze
47
+ end
48
+
49
+ private
50
+
51
+ def validate_header!
52
+ unless @published_schema.models.include?(source)
53
+ raise InvalidRelationshipError,
54
+ "Relationship source #{model_label(source)} is not published"
55
+ end
56
+ if name.empty?
57
+ raise InvalidRelationshipError, "Relationship name must not be empty"
58
+ end
59
+ unless %w[one many].include?(kind)
60
+ raise InvalidRelationshipError,
61
+ "Relationship #{name} kind must be one or many"
62
+ end
63
+ end
64
+
65
+ def validate_hops!(definitions)
66
+ definitions = Array(definitions)
67
+ if definitions.empty?
68
+ raise InvalidRelationshipError,
69
+ "Relationship #{name} must define at least one hop"
70
+ end
71
+ if definitions.length > 2
72
+ raise InvalidRelationshipError,
73
+ "Relationship #{name} supports at most two hops"
74
+ end
75
+
76
+ current_source = source
77
+ definitions.map do |definition|
78
+ unless definition.respond_to?(:to_h)
79
+ raise InvalidRelationshipError,
80
+ "Relationship #{name} hop must be an object"
81
+ end
82
+
83
+ attributes = definition.to_h.symbolize_keys
84
+ source_fields = normalize_fields(attributes[:source_fields])
85
+ destination = attributes[:destination]
86
+ destination_fields = normalize_fields(attributes[:destination_fields])
87
+
88
+ validate_model!(destination)
89
+ validate_fields!(current_source, source_fields, "source")
90
+ validate_fields!(destination, destination_fields, "destination")
91
+ if source_fields.length != destination_fields.length
92
+ raise InvalidRelationshipError,
93
+ "Relationship #{name} hop fields must have matching arity"
94
+ end
95
+
96
+ current_source = destination
97
+ Hop.new(source_fields:, destination:, destination_fields:).freeze
98
+ end
99
+ end
100
+
101
+ def validate_model!(model)
102
+ return if @published_schema.models.include?(model)
103
+
104
+ raise InvalidRelationshipError,
105
+ "Relationship #{name} destination #{model_label(model)} is not published"
106
+ end
107
+
108
+ def validate_fields!(model, fields, side)
109
+ if fields.empty?
110
+ raise InvalidRelationshipError,
111
+ "Relationship #{name} #{side} fields must not be empty"
112
+ end
113
+
114
+ missing = fields - @published_schema.column_names_for(model)
115
+ return if missing.empty?
116
+
117
+ raise InvalidRelationshipError,
118
+ "Relationship #{name} #{side} field #{missing.first} is not published"
119
+ end
120
+
121
+ def normalize_fields(value)
122
+ Array(value).compact.map(&:to_s)
123
+ end
124
+
125
+ def model_label(model)
126
+ model.respond_to?(:name) && model.name.present? ? model.name : model.inspect
127
+ end
128
+ end
129
+ end
@@ -37,13 +37,31 @@ module ZeroRailsAdapter
37
37
  jsonb: "z.json()"
38
38
  }.freeze
39
39
 
40
- attr_reader :models
40
+ attr_reader :models, :crud_models
41
+
42
+ def initialize(published_schema: nil, crud_models: nil, manual_relationships: nil)
43
+ schema = published_schema || ZeroRailsAdapter.configuration.published_schema.call
44
+ @published_schema = PublishedSchema.new(
45
+ schema,
46
+ zero_key: ZeroRailsAdapter.configuration.zero_key
47
+ )
48
+ @models = @published_schema.models.sort_by(&:table_name)
49
+ relationship_definitions = manual_relationships ||
50
+ ZeroRailsAdapter.configuration.relationship_provider.call
51
+ @manual_relationships = Array(relationship_definitions).map do |definition|
52
+ Relationship.from_definition(definition, published_schema: @published_schema)
53
+ end
54
+ requested_crud_models = Array(
55
+ crud_models || ZeroRailsAdapter.configuration.crud_model_provider.call
56
+ )
57
+ unpublished = requested_crud_models - models
58
+ if unpublished.any?
59
+ labels = unpublished.map { |model| model.respond_to?(:name) ? model.name : model.inspect }
60
+ raise UnsafePublicationError,
61
+ "CRUD models must also be published: #{labels.join(', ')}"
62
+ end
41
63
 
42
- def initialize(models: nil)
43
- @models = Array(models || ZeroRailsAdapter.configuration.model_provider.call)
44
- .select { |model| active_record_model?(model) }
45
- .uniq
46
- .sort_by(&:table_name)
64
+ @crud_models = requested_crud_models.uniq.sort_by(&:table_name)
47
65
  end
48
66
 
49
67
  def schema
@@ -75,7 +93,7 @@ module ZeroRailsAdapter
75
93
 
76
94
  def table_definitions
77
95
  models.map do |model|
78
- columns = model.columns.map do |column|
96
+ columns = columns_for(model).map do |column|
79
97
  " #{property_name(column.name)}: #{zero_type(model, column)},"
80
98
  end.join("\n")
81
99
  keys = primary_keys(model).map { |key| quote(key) }.join(", ")
@@ -91,22 +109,20 @@ module ZeroRailsAdapter
91
109
  end
92
110
 
93
111
  def relationship_definitions
94
- relationships.group_by(&:first).map do |model, definitions|
95
- kinds = definitions.map do |_source, reflection, _destination|
96
- reflection.collection? ? "many" : "one"
97
- end.uniq.sort.join(", ")
98
- entries = definitions.map do |_source, reflection, destination|
99
- source_fields, destination_fields =
100
- relationship_fields(model, reflection, destination)
101
- kind = reflection.collection? ? "many" : "one"
102
-
103
- [
104
- "#{property_name(reflection.name)}: #{kind}({",
105
- " sourceField: [#{source_fields.map { |field| quote(field) }.join(', ')}],",
106
- " destSchema: #{variable_name(destination)},",
107
- " destField: [#{destination_fields.map { |field| quote(field) }.join(', ')}],",
108
- "}),"
109
- ].join("\n")
112
+ relationships.group_by(&:source).map do |model, definitions|
113
+ kinds = definitions.map(&:kind).uniq.sort.join(", ")
114
+ entries = definitions.map do |relationship|
115
+ hops = relationship.hops.map do |hop|
116
+ [
117
+ "{",
118
+ " sourceField: [#{hop.source_fields.map { |field| quote(field) }.join(', ')}],",
119
+ " destSchema: #{variable_name(hop.destination)},",
120
+ " destField: [#{hop.destination_fields.map { |field| quote(field) }.join(', ')}],",
121
+ "}"
122
+ ].join("\n")
123
+ end.join(", ")
124
+
125
+ "#{property_name(relationship.name)}: #{relationship.kind}(#{hops}),"
110
126
  end.join("\n")
111
127
 
112
128
  [
@@ -119,7 +135,7 @@ module ZeroRailsAdapter
119
135
 
120
136
  def schema_export
121
137
  table_vars = models.map { |model| variable_name(model) }.join(", ")
122
- relation_vars = relationships.map(&:first).uniq.map do |model|
138
+ relation_vars = relationships.map(&:source).uniq.map do |model|
123
139
  relationship_variable_name(model)
124
140
  end.join(", ")
125
141
 
@@ -140,7 +156,7 @@ module ZeroRailsAdapter
140
156
  end
141
157
 
142
158
  def argument_schemas
143
- models.flat_map do |model|
159
+ crud_models.flat_map do |model|
144
160
  [
145
161
  zod_schema(model, :create),
146
162
  zod_schema(model, :update),
@@ -162,7 +178,7 @@ module ZeroRailsAdapter
162
178
  end
163
179
 
164
180
  def mutator_export
165
- tables = models.map do |model|
181
+ tables = crud_models.map do |model|
166
182
  variable = variable_name(model)
167
183
  table = property_name(model.table_name)
168
184
  create_values = create_insert_values(model)
@@ -193,8 +209,8 @@ module ZeroRailsAdapter
193
209
 
194
210
  def create_insert_values(model)
195
211
  additions = []
196
- additions << "created_at: now" if model.column_names.include?("created_at")
197
- additions << "updated_at: now" if model.column_names.include?("updated_at")
212
+ additions << "created_at: now" if published_column?(model, "created_at")
213
+ additions << "updated_at: now" if published_column?(model, "updated_at")
198
214
  defaulted_columns(model).each do |column|
199
215
  value = if generated_attribute_names(model, :create).include?(column.name)
200
216
  "args.#{property_name(column.name)} ?? #{typescript_default(column)}"
@@ -208,7 +224,7 @@ module ZeroRailsAdapter
208
224
 
209
225
  def update_insert_values(model)
210
226
  additions = []
211
- additions << "updated_at: Date.now()" if model.column_names.include?("updated_at")
227
+ additions << "updated_at: Date.now()" if published_column?(model, "updated_at")
212
228
  object_with_additions(additions)
213
229
  end
214
230
 
@@ -220,21 +236,24 @@ module ZeroRailsAdapter
220
236
 
221
237
  def mutation_columns(model, action)
222
238
  primary = primary_keys(model)
239
+ immutable = (primary + Array(model.primary_key).compact.map(&:to_s)).uniq
223
240
  case action
224
241
  when :create
225
242
  allowed = generated_attribute_names(model, action)
226
- model.columns.select do |column|
243
+ columns_for(model).select do |column|
227
244
  !timestamp_column?(column) &&
228
245
  (primary.include?(column.name) || allowed.include?(column.name))
229
246
  end
230
247
  when :update
231
248
  allowed = generated_attribute_names(model, action)
232
- model.columns.select do |column|
249
+ columns_for(model).select do |column|
233
250
  primary.include?(column.name) ||
234
- (!timestamp_column?(column) && allowed.include?(column.name))
251
+ (!immutable.include?(column.name) &&
252
+ !timestamp_column?(column) &&
253
+ allowed.include?(column.name))
235
254
  end
236
255
  when :destroy
237
- model.columns.select { |column| primary.include?(column.name) }
256
+ columns_for(model).select { |column| primary.include?(column.name) }
238
257
  end
239
258
  end
240
259
 
@@ -250,7 +269,7 @@ module ZeroRailsAdapter
250
269
 
251
270
  def defaulted_columns(model)
252
271
  primary = primary_keys(model)
253
- model.columns.select do |column|
272
+ columns_for(model).select do |column|
254
273
  !primary.include?(column.name) &&
255
274
  !timestamp_column?(column) &&
256
275
  !column.null &&
@@ -264,7 +283,7 @@ module ZeroRailsAdapter
264
283
  return column.null ? "#{type}.optional()" : type
265
284
  end
266
285
 
267
- type = enum_values(model, column)&.then do |values|
286
+ type = native_enum_values(model, column)&.then do |values|
268
287
  "enumeration<#{values.map { |value| quote(value) }.join(' | ')}>()"
269
288
  end
270
289
  type ||= mapped_type(SCHEMA_TYPES, model, column)
@@ -273,7 +292,7 @@ module ZeroRailsAdapter
273
292
  end
274
293
 
275
294
  def zod_type(model, column, action)
276
- values = enum_values(model, column)
295
+ values = native_enum_values(model, column)
277
296
  type = if column.respond_to?(:array) && column.array
278
297
  "z.array(#{mapped_type(ZOD_TYPES, model, column)})"
279
298
  elsif values
@@ -300,18 +319,24 @@ module ZeroRailsAdapter
300
319
  end
301
320
  end
302
321
 
303
- def enum_values(model, column)
322
+ def native_enum_values(model, column)
323
+ return unless column.type.to_sym == :enum
324
+
304
325
  definition = model.defined_enums[column.name]
305
- definition&.keys
326
+ return definition.keys if definition
327
+
328
+ enum_types = model.connection.enum_types.to_h
329
+ enum_types[column.sql_type] ||
330
+ raise(UnsupportedColumnTypeError,
331
+ "Cannot read PostgreSQL enum values for #{model.name}.#{column.name}")
306
332
  end
307
333
 
308
334
  def schema_imports
309
335
  imports = %w[createSchema table]
310
336
  imports << "relationships" if relationships.any?
311
- imports << "enumeration" if models.any? { |model| model.defined_enums.any? }
312
337
  models.each do |model|
313
- model.columns.each do |column|
314
- imports << if enum_values(model, column)
338
+ columns_for(model).each do |column|
339
+ imports << if native_enum_values(model, column)
315
340
  "enumeration"
316
341
  elsif column.respond_to?(:array) && column.array
317
342
  "json"
@@ -334,14 +359,42 @@ module ZeroRailsAdapter
334
359
  end
335
360
 
336
361
  def relationships
337
- @relationships ||= models.flat_map do |model|
338
- model.reflect_on_all_associations.filter_map do |reflection|
339
- next if reflection.polymorphic? || reflection.options[:through]
340
-
341
- destination = reflection.klass
342
- [model, reflection, destination] if models.include?(destination)
343
- rescue NameError
344
- nil
362
+ @relationships ||= begin
363
+ automatic = models.flat_map do |model|
364
+ model.reflect_on_all_associations.filter_map do |reflection|
365
+ next if reflection.polymorphic? || reflection.options[:through]
366
+
367
+ destination = reflection.klass
368
+ next unless models.include?(destination)
369
+
370
+ source_fields, destination_fields =
371
+ relationship_fields(model, reflection, destination)
372
+ if source_fields.all? { |field| published_column?(model, field) } &&
373
+ destination_fields.all? { |field| published_column?(destination, field) }
374
+ Relationship.new(
375
+ source: model,
376
+ name: reflection.name,
377
+ kind: reflection.collection? ? :many : :one,
378
+ hops: [{
379
+ source_fields:,
380
+ destination:,
381
+ destination_fields:
382
+ }],
383
+ published_schema: @published_schema
384
+ )
385
+ end
386
+ rescue NameError
387
+ nil
388
+ end
389
+ end
390
+
391
+ manual_keys = @manual_relationships.map do |relationship|
392
+ [relationship.source, relationship.name]
393
+ end
394
+ (automatic.reject do |relationship|
395
+ manual_keys.include?([relationship.source, relationship.name])
396
+ end + @manual_relationships).sort_by do |relationship|
397
+ [relationship.source.table_name, relationship.name]
345
398
  end
346
399
  end
347
400
  end
@@ -366,10 +419,15 @@ module ZeroRailsAdapter
366
419
  end
367
420
 
368
421
  def primary_keys(model)
369
- keys = Array(model.primary_key).compact.map(&:to_s)
370
- return keys if keys.any?
422
+ @published_schema.zero_keys_for(model)
423
+ end
424
+
425
+ def columns_for(model)
426
+ @published_schema.columns_for(model)
427
+ end
371
428
 
372
- raise UnsupportedColumnTypeError, "#{model.name} has no primary key"
429
+ def published_column?(model, name)
430
+ columns_for(model).any? { |column| column.name == name.to_s }
373
431
  end
374
432
 
375
433
  def relationship_variable_name(model)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZeroRailsAdapter
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -11,6 +11,8 @@ require_relative "zero_rails_adapter/errors"
11
11
  require_relative "zero_rails_adapter/identity"
12
12
  require_relative "zero_rails_adapter/context"
13
13
  require_relative "zero_rails_adapter/configuration"
14
+ require_relative "zero_rails_adapter/published_schema"
15
+ require_relative "zero_rails_adapter/relationship"
14
16
  require_relative "zero_rails_adapter/request_verifiers/api_key"
15
17
  require_relative "zero_rails_adapter/registry"
16
18
  require_relative "zero_rails_adapter/mutator"
@@ -18,6 +20,7 @@ require_relative "zero_rails_adapter/mutation"
18
20
  require_relative "zero_rails_adapter/request"
19
21
  require_relative "zero_rails_adapter/crud/dispatcher"
20
22
  require_relative "zero_rails_adapter/type_script/generator"
23
+ require_relative "zero_rails_adapter/postgresql/publication_generator"
21
24
  require_relative "zero_rails_adapter/storage/zero_schema"
22
25
  require_relative "zero_rails_adapter/processor"
23
26
  require_relative "zero_rails_adapter/engine"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zero-rails-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zero Rails Adapter contributors
@@ -46,6 +46,7 @@ files:
46
46
  - lib/generators/zero_rails_adapter/install/templates/initializer.rb
47
47
  - lib/generators/zero_rails_adapter/mutator/mutator_generator.rb
48
48
  - lib/generators/zero_rails_adapter/mutator/templates/mutator.rb
49
+ - lib/generators/zero_rails_adapter/publication/publication_generator.rb
49
50
  - lib/generators/zero_rails_adapter/typescript/typescript_generator.rb
50
51
  - lib/zero_rails_adapter.rb
51
52
  - lib/zero_rails_adapter/configuration.rb
@@ -56,8 +57,11 @@ files:
56
57
  - lib/zero_rails_adapter/identity.rb
57
58
  - lib/zero_rails_adapter/mutation.rb
58
59
  - lib/zero_rails_adapter/mutator.rb
60
+ - lib/zero_rails_adapter/postgresql/publication_generator.rb
59
61
  - lib/zero_rails_adapter/processor.rb
62
+ - lib/zero_rails_adapter/published_schema.rb
60
63
  - lib/zero_rails_adapter/registry.rb
64
+ - lib/zero_rails_adapter/relationship.rb
61
65
  - lib/zero_rails_adapter/request.rb
62
66
  - lib/zero_rails_adapter/request_verifiers/api_key.rb
63
67
  - lib/zero_rails_adapter/storage/zero_schema.rb