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 +4 -4
- data/README.md +135 -16
- data/examples/nextjs/README.md +4 -2
- data/lib/generators/zero_rails_adapter/install/templates/initializer.rb +25 -4
- data/lib/generators/zero_rails_adapter/publication/publication_generator.rb +21 -0
- data/lib/zero_rails_adapter/configuration.rb +8 -14
- data/lib/zero_rails_adapter/crud/dispatcher.rb +12 -6
- data/lib/zero_rails_adapter/errors.rb +2 -0
- data/lib/zero_rails_adapter/postgresql/publication_generator.rb +36 -0
- data/lib/zero_rails_adapter/published_schema.rb +143 -0
- data/lib/zero_rails_adapter/relationship.rb +129 -0
- data/lib/zero_rails_adapter/type_script/generator.rb +110 -52
- data/lib/zero_rails_adapter/version.rb +1 -1
- data/lib/zero_rails_adapter.rb +3 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87460dcc3c79305e4ca81c9bd30800866a2a9c97709298955cdf16334723d442
|
|
4
|
+
data.tar.gz: 9c76ea660812fb14a1fa52fc148e00e973da5b670635dcd85242d781b93f0958
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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
|
-
|
|
96
|
-
`
|
|
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.
|
|
161
|
+
config.crud_model_provider = -> { [Article, Comment] }
|
|
105
162
|
end
|
|
106
163
|
```
|
|
107
164
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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,
|
|
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
|
|
137
|
-
using Zero's current `defineMutator` /
|
|
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 → `
|
|
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
|
|
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)
|
data/examples/nextjs/README.md
CHANGED
|
@@ -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,
|
|
29
|
-
|
|
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|
-
:
|
|
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) {
|
|
15
|
+
@crud_authorizer = ->(_context, _action, _target, _attributes) { false }
|
|
15
16
|
@logger = defined?(Rails) ? Rails.logger : nil
|
|
16
17
|
@transaction_class = ActiveRecord::Base
|
|
17
|
-
@
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
90
|
-
details: {"
|
|
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
|
|
98
|
-
Array(
|
|
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
|
-
|
|
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.
|
|
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(&:
|
|
95
|
-
kinds = definitions.map
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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(&:
|
|
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
|
-
|
|
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 =
|
|
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
|
|
197
|
-
additions << "updated_at: now" if
|
|
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
|
|
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.
|
|
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.
|
|
249
|
+
columns_for(model).select do |column|
|
|
233
250
|
primary.include?(column.name) ||
|
|
234
|
-
(!
|
|
251
|
+
(!immutable.include?(column.name) &&
|
|
252
|
+
!timestamp_column?(column) &&
|
|
253
|
+
allowed.include?(column.name))
|
|
235
254
|
end
|
|
236
255
|
when :destroy
|
|
237
|
-
model.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
314
|
-
imports << if
|
|
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 ||=
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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)
|
data/lib/zero_rails_adapter.rb
CHANGED
|
@@ -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.
|
|
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
|