zero-rails-adapter 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +344 -0
- data/app/controllers/zero_rails_adapter/mutations_controller.rb +74 -0
- data/config/routes.rb +7 -0
- data/examples/nextjs/README.md +29 -0
- data/examples/nextjs/src/zero/mutators.ts +37 -0
- data/examples/nextjs/src/zero/schema.ts +20 -0
- data/lib/generators/zero_rails_adapter/install/install_generator.rb +15 -0
- data/lib/generators/zero_rails_adapter/install/templates/initializer.rb +52 -0
- data/lib/generators/zero_rails_adapter/mutator/mutator_generator.rb +31 -0
- data/lib/generators/zero_rails_adapter/mutator/templates/mutator.rb +14 -0
- data/lib/generators/zero_rails_adapter/typescript/typescript_generator.rb +18 -0
- data/lib/zero_rails_adapter/configuration.rb +57 -0
- data/lib/zero_rails_adapter/context.rb +19 -0
- data/lib/zero_rails_adapter/crud/dispatcher.rb +121 -0
- data/lib/zero_rails_adapter/engine.rb +7 -0
- data/lib/zero_rails_adapter/errors.rb +50 -0
- data/lib/zero_rails_adapter/identity.rb +9 -0
- data/lib/zero_rails_adapter/mutation.rb +50 -0
- data/lib/zero_rails_adapter/mutator.rb +70 -0
- data/lib/zero_rails_adapter/processor.rb +178 -0
- data/lib/zero_rails_adapter/registry.rb +51 -0
- data/lib/zero_rails_adapter/request.rb +93 -0
- data/lib/zero_rails_adapter/request_verifiers/api_key.rb +21 -0
- data/lib/zero_rails_adapter/storage/zero_schema.rb +95 -0
- data/lib/zero_rails_adapter/type_script/generator.rb +421 -0
- data/lib/zero_rails_adapter/version.rb +5 -0
- data/lib/zero_rails_adapter.rb +50 -0
- metadata +89 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class Request
|
|
5
|
+
REQUIRED_KEYS = %w[clientGroupID mutations pushVersion timestamp requestID].freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :client_group_id, :mutations, :push_version, :timestamp,
|
|
8
|
+
:request_id, :schema, :app_id, :body
|
|
9
|
+
|
|
10
|
+
def self.parse(body, query:)
|
|
11
|
+
body = body.to_h.stringify_keys if body.respond_to?(:to_h)
|
|
12
|
+
query = query.to_h.stringify_keys if query.respond_to?(:to_h)
|
|
13
|
+
mutation_ids = extract_mutation_ids(body)
|
|
14
|
+
|
|
15
|
+
validate_body!(body, mutation_ids:)
|
|
16
|
+
validate_query!(query, mutation_ids:)
|
|
17
|
+
|
|
18
|
+
unless body["pushVersion"] == 1
|
|
19
|
+
raise UnsupportedPushVersionError.new(body["pushVersion"], mutation_ids:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
mutations = body["mutations"].map { |value| Mutation.parse(value) }
|
|
23
|
+
new(
|
|
24
|
+
body:,
|
|
25
|
+
client_group_id: body["clientGroupID"],
|
|
26
|
+
mutations:,
|
|
27
|
+
push_version: body["pushVersion"],
|
|
28
|
+
timestamp: body["timestamp"],
|
|
29
|
+
request_id: body["requestID"],
|
|
30
|
+
schema: query["schema"],
|
|
31
|
+
app_id: query["appID"]
|
|
32
|
+
)
|
|
33
|
+
rescue ParseError => error
|
|
34
|
+
if error.mutation_ids.empty?
|
|
35
|
+
raise ParseError.new(error.message, mutation_ids:, source: error.source)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
raise
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.validate_body!(body, mutation_ids:)
|
|
42
|
+
raise ParseError.new("Push body must be an object", mutation_ids:) unless body.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
missing = REQUIRED_KEYS.reject { |key| body.key?(key) }
|
|
45
|
+
raise ParseError.new("Push body is missing #{missing.join(', ')}", mutation_ids:) if missing.any?
|
|
46
|
+
raise ParseError.new("clientGroupID must be a string", mutation_ids:) unless body["clientGroupID"].is_a?(String)
|
|
47
|
+
raise ParseError.new("mutations must be an array", mutation_ids:) unless body["mutations"].is_a?(Array)
|
|
48
|
+
raise ParseError.new("pushVersion must be a number", mutation_ids:) unless body["pushVersion"].is_a?(Numeric)
|
|
49
|
+
raise ParseError.new("timestamp must be a number", mutation_ids:) unless body["timestamp"].is_a?(Numeric)
|
|
50
|
+
raise ParseError.new("requestID must be a string", mutation_ids:) unless body["requestID"].is_a?(String)
|
|
51
|
+
end
|
|
52
|
+
private_class_method :validate_body!
|
|
53
|
+
|
|
54
|
+
def self.validate_query!(query, mutation_ids:)
|
|
55
|
+
unless query.is_a?(Hash) && query["schema"].is_a?(String) && query["appID"].is_a?(String)
|
|
56
|
+
raise ParseError.new(
|
|
57
|
+
"Query parameters schema and appID are required",
|
|
58
|
+
mutation_ids:,
|
|
59
|
+
source: :query
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless query["schema"].match?(/\A[a-z0-9_]+\z/)
|
|
64
|
+
raise ParseError.new(
|
|
65
|
+
"Query parameter schema is invalid",
|
|
66
|
+
mutation_ids:,
|
|
67
|
+
source: :query
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
private_class_method :validate_query!
|
|
72
|
+
|
|
73
|
+
def self.extract_mutation_ids(body)
|
|
74
|
+
return [] unless body.is_a?(Hash) && body["mutations"].is_a?(Array)
|
|
75
|
+
|
|
76
|
+
body["mutations"].filter_map do |mutation|
|
|
77
|
+
next unless mutation.is_a?(Hash)
|
|
78
|
+
next unless mutation["clientID"].is_a?(String) && mutation["id"].is_a?(Numeric)
|
|
79
|
+
|
|
80
|
+
{"clientID" => mutation["clientID"], "id" => mutation["id"]}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
private_class_method :extract_mutation_ids
|
|
84
|
+
|
|
85
|
+
def initialize(**attributes)
|
|
86
|
+
attributes.each { |name, value| instance_variable_set(:"@#{name}", value) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def mutation_ids
|
|
90
|
+
mutations.map(&:identifier)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/security_utils"
|
|
4
|
+
|
|
5
|
+
module ZeroRailsAdapter
|
|
6
|
+
module RequestVerifiers
|
|
7
|
+
class ApiKey
|
|
8
|
+
def initialize(key:, header: "X-Api-Key")
|
|
9
|
+
@key = key.to_s
|
|
10
|
+
@header = header
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(request)
|
|
14
|
+
candidate = request.headers[@header].to_s
|
|
15
|
+
return false if @key.empty? || candidate.bytesize != @key.bytesize
|
|
16
|
+
|
|
17
|
+
ActiveSupport::SecurityUtils.secure_compare(candidate, @key)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
module Storage
|
|
5
|
+
class ZeroSchema
|
|
6
|
+
class << self
|
|
7
|
+
def model(base_class:, table_name:, primary_key:)
|
|
8
|
+
key = [base_class, table_name, primary_key]
|
|
9
|
+
models_mutex.synchronize do
|
|
10
|
+
models[key] ||= Class.new(base_class) do
|
|
11
|
+
self.table_name = table_name
|
|
12
|
+
self.primary_key = primary_key
|
|
13
|
+
self.inheritance_column = :_zero_rails_adapter_type
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def models
|
|
21
|
+
@models ||= {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def models_mutex
|
|
25
|
+
@models_mutex ||= Mutex.new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :request, :transaction_class, :client_model,
|
|
30
|
+
:mutation_result_model
|
|
31
|
+
|
|
32
|
+
def initialize(request:, transaction_class:)
|
|
33
|
+
@request = request
|
|
34
|
+
@transaction_class = transaction_class
|
|
35
|
+
@client_model = self.class.model(
|
|
36
|
+
base_class: transaction_class,
|
|
37
|
+
table_name: "#{request.schema}.clients",
|
|
38
|
+
primary_key: %w[clientGroupID clientID]
|
|
39
|
+
)
|
|
40
|
+
@mutation_result_model = self.class.model(
|
|
41
|
+
base_class: transaction_class,
|
|
42
|
+
table_name: "#{request.schema}.mutations",
|
|
43
|
+
primary_key: %w[clientGroupID clientID mutationID]
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def transaction(&block)
|
|
48
|
+
transaction_class.transaction(requires_new: true, &block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def increment_lmid!(client_id)
|
|
52
|
+
attributes = client_identity(client_id)
|
|
53
|
+
record = client_model.create_or_find_by!(attributes) do |client|
|
|
54
|
+
client["lastMutationID"] = 1
|
|
55
|
+
end
|
|
56
|
+
created = record.previously_new_record?
|
|
57
|
+
record.lock!
|
|
58
|
+
record.update!("lastMutationID" => record["lastMutationID"].to_i + 1) unless created
|
|
59
|
+
record["lastMutationID"].to_i
|
|
60
|
+
rescue ActiveRecord::RecordNotUnique
|
|
61
|
+
retry
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def write_result(client_id:, mutation_id:, result:)
|
|
65
|
+
mutation_result_model.create!(
|
|
66
|
+
"clientGroupID" => request.client_group_id,
|
|
67
|
+
"clientID" => client_id,
|
|
68
|
+
"mutationID" => mutation_id,
|
|
69
|
+
"result" => result
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cleanup(args)
|
|
74
|
+
scope = mutation_result_model.where("clientGroupID" => args["clientGroupID"])
|
|
75
|
+
|
|
76
|
+
if args["type"] == "bulk"
|
|
77
|
+
scope.where("clientID" => Array(args["clientIDs"])).delete_all
|
|
78
|
+
elsif args["clientID"].is_a?(String) && args["upToMutationID"].is_a?(Numeric)
|
|
79
|
+
scope.where("clientID" => args["clientID"])
|
|
80
|
+
.where('"mutationID" <= ?', args["upToMutationID"])
|
|
81
|
+
.delete_all
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def client_identity(client_id)
|
|
88
|
+
{
|
|
89
|
+
"clientGroupID" => request.client_group_id,
|
|
90
|
+
"clientID" => client_id
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
module TypeScript
|
|
5
|
+
class Generator
|
|
6
|
+
SCHEMA_TYPES = {
|
|
7
|
+
string: "string()",
|
|
8
|
+
text: "string()",
|
|
9
|
+
uuid: "string()",
|
|
10
|
+
integer: "number()",
|
|
11
|
+
bigint: "number()",
|
|
12
|
+
float: "number()",
|
|
13
|
+
decimal: "number()",
|
|
14
|
+
boolean: "boolean()",
|
|
15
|
+
date: "number()",
|
|
16
|
+
datetime: "number()",
|
|
17
|
+
timestamp: "number()",
|
|
18
|
+
time: "number()",
|
|
19
|
+
json: "json()",
|
|
20
|
+
jsonb: "json()"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
ZOD_TYPES = {
|
|
24
|
+
string: "z.string()",
|
|
25
|
+
text: "z.string()",
|
|
26
|
+
uuid: "z.string().uuid()",
|
|
27
|
+
integer: "z.number()",
|
|
28
|
+
bigint: "z.number()",
|
|
29
|
+
float: "z.number()",
|
|
30
|
+
decimal: "z.number()",
|
|
31
|
+
boolean: "z.boolean()",
|
|
32
|
+
date: "z.number()",
|
|
33
|
+
datetime: "z.number()",
|
|
34
|
+
timestamp: "z.number()",
|
|
35
|
+
time: "z.number()",
|
|
36
|
+
json: "z.json()",
|
|
37
|
+
jsonb: "z.json()"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
attr_reader :models
|
|
41
|
+
|
|
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)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def schema
|
|
50
|
+
sections = [
|
|
51
|
+
"import {#{schema_imports.join(', ')}} from '@rocicorp/zero'",
|
|
52
|
+
table_definitions,
|
|
53
|
+
relationship_definitions,
|
|
54
|
+
schema_export
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
sections.reject(&:empty?).join("\n\n") << "\n"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mutators
|
|
61
|
+
sections = [
|
|
62
|
+
"import {defineMutator, defineMutators} from '@rocicorp/zero'\nimport {z} from 'zod'",
|
|
63
|
+
argument_schemas,
|
|
64
|
+
mutator_export
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
sections.reject(&:empty?).join("\n\n") << "\n"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def active_record_model?(model)
|
|
73
|
+
model.is_a?(Class) && model < ActiveRecord::Base && !model.abstract_class?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def table_definitions
|
|
77
|
+
models.map do |model|
|
|
78
|
+
columns = model.columns.map do |column|
|
|
79
|
+
" #{property_name(column.name)}: #{zero_type(model, column)},"
|
|
80
|
+
end.join("\n")
|
|
81
|
+
keys = primary_keys(model).map { |key| quote(key) }.join(", ")
|
|
82
|
+
|
|
83
|
+
<<~TYPESCRIPT.chomp
|
|
84
|
+
const #{variable_name(model)} = table(#{quote(model.table_name)})
|
|
85
|
+
.columns({
|
|
86
|
+
#{columns}
|
|
87
|
+
})
|
|
88
|
+
.primaryKey(#{keys})
|
|
89
|
+
TYPESCRIPT
|
|
90
|
+
end.join("\n\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
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")
|
|
110
|
+
end.join("\n")
|
|
111
|
+
|
|
112
|
+
[
|
|
113
|
+
"const #{relationship_variable_name(model)} = relationships(#{variable_name(model)}, ({#{kinds}}) => ({",
|
|
114
|
+
indent(entries, 2),
|
|
115
|
+
"}))"
|
|
116
|
+
].join("\n")
|
|
117
|
+
end.join("\n\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def schema_export
|
|
121
|
+
table_vars = models.map { |model| variable_name(model) }.join(", ")
|
|
122
|
+
relation_vars = relationships.map(&:first).uniq.map do |model|
|
|
123
|
+
relationship_variable_name(model)
|
|
124
|
+
end.join(", ")
|
|
125
|
+
|
|
126
|
+
lines = ["export const schema = createSchema({", " tables: [#{table_vars}],"]
|
|
127
|
+
lines << " relationships: [#{relation_vars}]," unless relation_vars.empty?
|
|
128
|
+
lines.concat([
|
|
129
|
+
"})",
|
|
130
|
+
"",
|
|
131
|
+
"export type Schema = typeof schema",
|
|
132
|
+
"",
|
|
133
|
+
"declare module '@rocicorp/zero' {",
|
|
134
|
+
" interface DefaultTypes {",
|
|
135
|
+
" schema: Schema",
|
|
136
|
+
" }",
|
|
137
|
+
"}"
|
|
138
|
+
])
|
|
139
|
+
lines.join("\n")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def argument_schemas
|
|
143
|
+
models.flat_map do |model|
|
|
144
|
+
[
|
|
145
|
+
zod_schema(model, :create),
|
|
146
|
+
zod_schema(model, :update),
|
|
147
|
+
zod_schema(model, :destroy)
|
|
148
|
+
]
|
|
149
|
+
end.join("\n\n")
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def zod_schema(model, action)
|
|
153
|
+
columns = mutation_columns(model, action).map do |column|
|
|
154
|
+
" #{property_name(column.name)}: #{zod_type(model, column, action)},"
|
|
155
|
+
end.join("\n")
|
|
156
|
+
|
|
157
|
+
<<~TYPESCRIPT.chomp
|
|
158
|
+
const #{variable_name(model)}#{action.to_s.camelize}Args = z.object({
|
|
159
|
+
#{columns}
|
|
160
|
+
})
|
|
161
|
+
TYPESCRIPT
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def mutator_export
|
|
165
|
+
tables = models.map do |model|
|
|
166
|
+
variable = variable_name(model)
|
|
167
|
+
table = property_name(model.table_name)
|
|
168
|
+
create_values = create_insert_values(model)
|
|
169
|
+
update_values = update_insert_values(model)
|
|
170
|
+
|
|
171
|
+
[
|
|
172
|
+
"#{table}: {",
|
|
173
|
+
" create: defineMutator(#{variable}CreateArgs, async ({tx, args}) => {",
|
|
174
|
+
" const now = Date.now()",
|
|
175
|
+
" await tx.mutate.#{table}.insert(#{create_values})",
|
|
176
|
+
" }),",
|
|
177
|
+
" update: defineMutator(#{variable}UpdateArgs, async ({tx, args}) => {",
|
|
178
|
+
" await tx.mutate.#{table}.update(#{update_values})",
|
|
179
|
+
" }),",
|
|
180
|
+
" destroy: defineMutator(#{variable}DestroyArgs, async ({tx, args}) => {",
|
|
181
|
+
" await tx.mutate.#{table}.delete(args)",
|
|
182
|
+
" }),",
|
|
183
|
+
"},"
|
|
184
|
+
].join("\n")
|
|
185
|
+
end.join("\n")
|
|
186
|
+
|
|
187
|
+
[
|
|
188
|
+
"export const mutators = defineMutators({",
|
|
189
|
+
indent(tables, 2),
|
|
190
|
+
"})"
|
|
191
|
+
].join("\n")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def create_insert_values(model)
|
|
195
|
+
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")
|
|
198
|
+
defaulted_columns(model).each do |column|
|
|
199
|
+
value = if generated_attribute_names(model, :create).include?(column.name)
|
|
200
|
+
"args.#{property_name(column.name)} ?? #{typescript_default(column)}"
|
|
201
|
+
else
|
|
202
|
+
typescript_default(column)
|
|
203
|
+
end
|
|
204
|
+
additions << "#{property_name(column.name)}: #{value}"
|
|
205
|
+
end
|
|
206
|
+
object_with_additions(additions)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def update_insert_values(model)
|
|
210
|
+
additions = []
|
|
211
|
+
additions << "updated_at: Date.now()" if model.column_names.include?("updated_at")
|
|
212
|
+
object_with_additions(additions)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def object_with_additions(additions)
|
|
216
|
+
return "args" if additions.empty?
|
|
217
|
+
|
|
218
|
+
"{...args, #{additions.join(', ')}}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def mutation_columns(model, action)
|
|
222
|
+
primary = primary_keys(model)
|
|
223
|
+
case action
|
|
224
|
+
when :create
|
|
225
|
+
allowed = generated_attribute_names(model, action)
|
|
226
|
+
model.columns.select do |column|
|
|
227
|
+
!timestamp_column?(column) &&
|
|
228
|
+
(primary.include?(column.name) || allowed.include?(column.name))
|
|
229
|
+
end
|
|
230
|
+
when :update
|
|
231
|
+
allowed = generated_attribute_names(model, action)
|
|
232
|
+
model.columns.select do |column|
|
|
233
|
+
primary.include?(column.name) ||
|
|
234
|
+
(!timestamp_column?(column) && allowed.include?(column.name))
|
|
235
|
+
end
|
|
236
|
+
when :destroy
|
|
237
|
+
model.columns.select { |column| primary.include?(column.name) }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def generated_attribute_names(model, action)
|
|
242
|
+
Array(
|
|
243
|
+
ZeroRailsAdapter.configuration.generated_attributes.call(model, action)
|
|
244
|
+
).map(&:to_s)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def timestamp_column?(column)
|
|
248
|
+
%w[created_at updated_at].include?(column.name)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def defaulted_columns(model)
|
|
252
|
+
primary = primary_keys(model)
|
|
253
|
+
model.columns.select do |column|
|
|
254
|
+
!primary.include?(column.name) &&
|
|
255
|
+
!timestamp_column?(column) &&
|
|
256
|
+
!column.null &&
|
|
257
|
+
!column.default.nil?
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def zero_type(model, column)
|
|
262
|
+
if column.respond_to?(:array) && column.array
|
|
263
|
+
type = "json<readonly #{typescript_scalar(column)}[]>()"
|
|
264
|
+
return column.null ? "#{type}.optional()" : type
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
type = enum_values(model, column)&.then do |values|
|
|
268
|
+
"enumeration<#{values.map { |value| quote(value) }.join(' | ')}>()"
|
|
269
|
+
end
|
|
270
|
+
type ||= mapped_type(SCHEMA_TYPES, model, column)
|
|
271
|
+
type += ".optional()" if column.null
|
|
272
|
+
type
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def zod_type(model, column, action)
|
|
276
|
+
values = enum_values(model, column)
|
|
277
|
+
type = if column.respond_to?(:array) && column.array
|
|
278
|
+
"z.array(#{mapped_type(ZOD_TYPES, model, column)})"
|
|
279
|
+
elsif values
|
|
280
|
+
"z.enum([#{values.map { |value| quote(value) }.join(', ')}])"
|
|
281
|
+
else
|
|
282
|
+
mapped_type(ZOD_TYPES, model, column)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
if column.null
|
|
286
|
+
"#{type}.nullish()"
|
|
287
|
+
elsif action == :update && !primary_keys(model).include?(column.name)
|
|
288
|
+
"#{type}.optional()"
|
|
289
|
+
elsif action == :create && !column.default.nil?
|
|
290
|
+
"#{type}.optional()"
|
|
291
|
+
else
|
|
292
|
+
type
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def mapped_type(mapping, model, column)
|
|
297
|
+
mapping.fetch(column.type.to_sym) do
|
|
298
|
+
raise UnsupportedColumnTypeError,
|
|
299
|
+
"Cannot generate Zero TypeScript for #{model.name}.#{column.name} (#{column.type})"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def enum_values(model, column)
|
|
304
|
+
definition = model.defined_enums[column.name]
|
|
305
|
+
definition&.keys
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def schema_imports
|
|
309
|
+
imports = %w[createSchema table]
|
|
310
|
+
imports << "relationships" if relationships.any?
|
|
311
|
+
imports << "enumeration" if models.any? { |model| model.defined_enums.any? }
|
|
312
|
+
models.each do |model|
|
|
313
|
+
model.columns.each do |column|
|
|
314
|
+
imports << if enum_values(model, column)
|
|
315
|
+
"enumeration"
|
|
316
|
+
elsif column.respond_to?(:array) && column.array
|
|
317
|
+
"json"
|
|
318
|
+
else
|
|
319
|
+
mapped_type(SCHEMA_TYPES, model, column).delete_suffix("()")
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
imports.uniq.sort
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def typescript_scalar(column)
|
|
327
|
+
case column.type.to_sym
|
|
328
|
+
when :string, :text, :uuid then "string"
|
|
329
|
+
when :integer, :bigint, :float, :decimal, :date, :datetime, :timestamp, :time
|
|
330
|
+
"number"
|
|
331
|
+
when :boolean then "boolean"
|
|
332
|
+
else "unknown"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
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
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def relationship_fields(model, reflection, destination)
|
|
350
|
+
if reflection.belongs_to?
|
|
351
|
+
[
|
|
352
|
+
Array(reflection.foreign_key).map(&:to_s),
|
|
353
|
+
association_primary_keys(reflection, destination)
|
|
354
|
+
]
|
|
355
|
+
else
|
|
356
|
+
[
|
|
357
|
+
association_primary_keys(reflection, model),
|
|
358
|
+
Array(reflection.foreign_key).map(&:to_s)
|
|
359
|
+
]
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def association_primary_keys(reflection, model)
|
|
364
|
+
configured = reflection.options[:primary_key]
|
|
365
|
+
Array(configured || model.primary_key).map(&:to_s)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def primary_keys(model)
|
|
369
|
+
keys = Array(model.primary_key).compact.map(&:to_s)
|
|
370
|
+
return keys if keys.any?
|
|
371
|
+
|
|
372
|
+
raise UnsupportedColumnTypeError, "#{model.name} has no primary key"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def relationship_variable_name(model)
|
|
376
|
+
"#{variable_name(model)}Relationships"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def variable_name(model)
|
|
380
|
+
model.table_name.gsub(/[^a-zA-Z0-9_]/, "_").camelize(:lower)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def property_name(value)
|
|
384
|
+
string = value.to_s
|
|
385
|
+
string.match?(/\A[$A-Z_a-z][$\w]*\z/) ? string : quote(string)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def quote(value)
|
|
389
|
+
"'#{value.to_s.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'")}'"
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def typescript_literal(value)
|
|
393
|
+
case value
|
|
394
|
+
when String then quote(value)
|
|
395
|
+
when Numeric then value.to_s
|
|
396
|
+
when true then "true"
|
|
397
|
+
when false then "false"
|
|
398
|
+
else
|
|
399
|
+
raise UnsupportedColumnTypeError, "Cannot serialize database default #{value.inspect}"
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def typescript_default(column)
|
|
404
|
+
value = column.default
|
|
405
|
+
case column.type.to_sym
|
|
406
|
+
when :boolean
|
|
407
|
+
ActiveModel::Type::Boolean.new.cast(value) ? "true" : "false"
|
|
408
|
+
when :integer, :bigint, :float, :decimal
|
|
409
|
+
value.to_s
|
|
410
|
+
else
|
|
411
|
+
typescript_literal(value)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def indent(value, spaces)
|
|
416
|
+
prefix = " " * spaces
|
|
417
|
+
value.lines.map { |line| "#{prefix}#{line}" }.join.chomp
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "active_record"
|
|
5
|
+
require "active_model"
|
|
6
|
+
require "active_support"
|
|
7
|
+
require "active_support/core_ext/hash/keys"
|
|
8
|
+
require "active_support/core_ext/string/inflections"
|
|
9
|
+
require_relative "zero_rails_adapter/version"
|
|
10
|
+
require_relative "zero_rails_adapter/errors"
|
|
11
|
+
require_relative "zero_rails_adapter/identity"
|
|
12
|
+
require_relative "zero_rails_adapter/context"
|
|
13
|
+
require_relative "zero_rails_adapter/configuration"
|
|
14
|
+
require_relative "zero_rails_adapter/request_verifiers/api_key"
|
|
15
|
+
require_relative "zero_rails_adapter/registry"
|
|
16
|
+
require_relative "zero_rails_adapter/mutator"
|
|
17
|
+
require_relative "zero_rails_adapter/mutation"
|
|
18
|
+
require_relative "zero_rails_adapter/request"
|
|
19
|
+
require_relative "zero_rails_adapter/crud/dispatcher"
|
|
20
|
+
require_relative "zero_rails_adapter/type_script/generator"
|
|
21
|
+
require_relative "zero_rails_adapter/storage/zero_schema"
|
|
22
|
+
require_relative "zero_rails_adapter/processor"
|
|
23
|
+
require_relative "zero_rails_adapter/engine"
|
|
24
|
+
|
|
25
|
+
module ZeroRailsAdapter
|
|
26
|
+
class << self
|
|
27
|
+
def configuration
|
|
28
|
+
@configuration ||= Configuration.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configure
|
|
32
|
+
yield configuration
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def reset_configuration!
|
|
36
|
+
@configuration = Configuration.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def registry
|
|
40
|
+
@registry ||= Registry.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def define_mutator(name, superclass: Mutator, &definition)
|
|
44
|
+
Class.new(superclass).tap do |mutator_class|
|
|
45
|
+
mutator_class.mutation_name(name)
|
|
46
|
+
mutator_class.class_eval(&definition)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|