Class: Synthra::ActiveRecordInference

Inherits:
Object
  • Object
show all
Defined in:
lib/synthra/activerecord_inference.rb

Overview

ActiveRecord Schema Inference

Automatically generates DSL schemas from ActiveRecord models by analyzing column types, validations, and associations.

Examples:

Infer schema from model

schema = Synthra::ActiveRecordInference.infer(User)
schema.generate

Discover all models

Synthra::ActiveRecordInference.discover_all!

Export inferred schema to DSL file

Synthra::ActiveRecordInference.export(User, "schemas/user.dsl")

Defined Under Namespace

Classes: SchemaBuilder

Constant Summary collapse

COLUMN_TYPE_MAP =

Column type to Synthra type mapping

{
  # Strings
  string: "text",
  text: "paragraph",
  citext: "text",

  # Numbers
  integer: "number",
  bigint: "number",
  smallint: "number",
  float: "float",
  decimal: "money",
  numeric: "money",

  # Boolean
  boolean: "boolean",

  # Date/Time
  date: "date",
  datetime: "timestamp",
  timestamp: "timestamp",
  time: "time",

  # Binary
  binary: "binary",
  blob: "binary",

  # Special
  uuid: "uuid",
  json: "object",
  jsonb: "object",
  hstore: "object",
  array: "array(text)"
}.freeze
NAME_PATTERNS =

Column name patterns for smart type inference

{
  /\Aemail\z/i => "email",
  /email/i => "email",
  /\Aphone\z/i => "phone",
  /phone|mobile|cell/i => "phone",
  /\Aurl\z/i => "url",
  /url|website|link|href/i => "url",
  /\Aname\z/i => "name",
  /first_name/i => "first_name",
  /last_name/i => "last_name",
  /full_name/i => "full_name",
  /username/i => "username",
  /password/i => "password",
  /\Aaddress\z/i => "address",
  /street/i => "street",
  /city/i => "city",
  /state|province/i => "state",
  /country/i => "country",
  /zip|postal/i => "postal_code",
  /latitude|lat\z/i => "latitude",
  /longitude|lng|lon\z/i => "longitude",
  /ip_address|ip\z/i => "ip",
  /user_agent/i => "user_agent",
  /color/i => "hex_color",
  /avatar|image|photo|picture/i => "url",
  /title/i => "sentence",
  /description|bio|about/i => "paragraph",
  /slug/i => "slug",
  /token|secret|api_key/i => "uuid",
  /amount|price|cost|total/i => "money",
  /count|quantity|qty/i => "number(1..100)",
  /age/i => "number(1..120)",
  /ssn/i => "ssn",
  /credit_card|card_number/i => "credit_card",
  /company|organization/i => "company",
  /job_title|position/i => "job_title"
}.freeze

Class Method Summary collapse

Class Method Details

.discover_all!(**options) ⇒ Array<Schema>

Discover and infer schemas for all ActiveRecord models

Parameters:

  • options (Hash)

    inference options

Returns:

  • (Array<Schema>)

    list of generated schemas



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/synthra/activerecord_inference.rb', line 144

def discover_all!(**options)
  # Ensure models are loaded
  Rails.application.eager_load! if defined?(Rails) && Rails.env.development?

  schemas = []

  ActiveRecord::Base.descendants.each do |model|
    next if model.abstract_class?
    next if model.name.nil?
    next if model.name.start_with?("ActiveRecord::")
    next if model.name.start_with?("HABTM_")

    begin
      schema = infer(model, **options)
      Synthra.registry.register_schema(model.name, schema)
      schemas << schema
    rescue StandardError => e
      warn "[Synthra] Could not infer #{model.name}: #{e.message}"
    end
  end

  schemas
end

.export(model, path = nil, **options) ⇒ String

Export inferred schema to a DSL file

Parameters:

  • model (Class)

    ActiveRecord model class

  • path (String) (defaults to: nil)

    output file path

  • options (Hash)

    inference options

Returns:

  • (String)

    generated DSL content



175
176
177
178
179
180
181
182
183
184
185
# File 'lib/synthra/activerecord_inference.rb', line 175

def export(model, path = nil, **options)
  schema = infer(model, **options)
  dsl_content = schema_to_dsl(schema)

  if path
    FileUtils.mkdir_p(File.dirname(path))
    File.write(path, dsl_content)
  end

  dsl_content
end

.export_all(output_dir = "db/schemas", **options) ⇒ Object

Export all discovered models to DSL files

Parameters:

  • output_dir (String) (defaults to: "db/schemas")

    output directory

  • options (Hash)

    inference options



192
193
194
195
196
197
198
199
200
201
# File 'lib/synthra/activerecord_inference.rb', line 192

def export_all(output_dir = "db/schemas", **options)
  FileUtils.mkdir_p(output_dir)

  discover_all!(**options).each do |schema|
    filename = schema.name.underscore + ".dsl"
    path = File.join(output_dir, filename)
    dsl_content = schema_to_dsl(schema)
    File.write(path, dsl_content)
  end
end

.infer(model, **options) ⇒ Schema

Infer schema from an ActiveRecord model

Parameters:

  • model (Class)

    ActiveRecord model class

  • options (Hash)

    inference options

Options Hash (**options):

  • :include_associations (Boolean)

    include associations

  • :include_validations (Boolean)

    infer from validations

  • :skip_columns (Array<String>)

    columns to skip

Returns:

  • (Schema)

    generated schema



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/synthra/activerecord_inference.rb', line 105

def infer(model, **options)
  options = {
    include_associations: true,
    include_validations: true,
    skip_columns: %w[id created_at updated_at]
  }.merge(options)

  builder = SchemaBuilder.new(model.name)

  # Process columns
  model.columns.each do |column|
    next if options[:skip_columns].include?(column.name)

    field_type = infer_column_type(column, model, options)
    field_options = infer_field_options(column, model, options)

    builder.add_field(column.name, field_type, **field_options)
  end

  # Process associations
  if options[:include_associations]
    model.reflect_on_all_associations.each do |assoc|
      process_association(builder, assoc)
    end
  end

  # Process validations
  if options[:include_validations]
    process_validations(builder, model)
  end

  builder.build
end

.infer_column_type(column, model, options) ⇒ Object (private)



205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/synthra/activerecord_inference.rb', line 205

def infer_column_type(column, model, options)
  # First, check name patterns
  NAME_PATTERNS.each do |pattern, type|
    return type if column.name.match?(pattern)
  end

  # Check for enums
  if model.defined_enums.key?(column.name)
    values = model.defined_enums[column.name].keys
    return "enum(#{values.join(', ')})"
  end

  # Check validations for format hints
  if options[:include_validations]
    type_from_validations = infer_from_validations(column, model)
    return type_from_validations if type_from_validations
  end

  # Fall back to column type mapping
  COLUMN_TYPE_MAP[column.type] || "text"
end

.infer_field_options(column, model, _options) ⇒ Object (private)



227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/synthra/activerecord_inference.rb', line 227

def infer_field_options(column, model, _options)
  opts = {}

  # Check nullability
  opts[:nullable] = column.null

  # Check default value
  opts[:default] = column.default if column.default

  # Check for unique constraint
  if model.validators_on(column.name).any? { |v| v.is_a?(ActiveRecord::Validations::UniquenessValidator) }
    opts[:unique] = true
  end

  opts
end

.infer_from_validations(column, model) ⇒ Object (private)



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/synthra/activerecord_inference.rb', line 297

def infer_from_validations(column, model)
  model.validators_on(column.name).each do |validator|
    case validator
    when ActiveModel::Validations::FormatValidator
      pattern = validator.options[:with]
      return "email" if pattern.to_s.include?("@")
      return "url" if pattern.to_s.include?("http")

    when ActiveModel::Validations::InclusionValidator
      if validator.options[:in].is_a?(Array)
        values = validator.options[:in]
        return "enum(#{values.join(', ')})"
      end
    end
  end

  nil
end

.process_association(builder, assoc) ⇒ Object (private)



244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/synthra/activerecord_inference.rb', line 244

def process_association(builder, assoc)
  case assoc.macro
  when :belongs_to
    # Add foreign key reference
    fk_column = assoc.foreign_key
    unless builder.has_field?(fk_column)
      ref_type = "Ref(#{assoc.class_name}.id)"
      builder.add_field(fk_column, ref_type, optional: assoc.options[:optional])
    end

  when :has_one
    # Record the relationship for potential use
    builder.add_relationship(:has_one, assoc.name, assoc.class_name)

  when :has_many
    # Record the relationship
    builder.add_relationship(:has_many, assoc.name, assoc.class_name)
  end
end

.process_validations(builder, model) ⇒ Object (private)



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/synthra/activerecord_inference.rb', line 264

def process_validations(builder, model)
  model.validators.each do |validator|
    validator.attributes.each do |attr|
      case validator
      when ActiveModel::Validations::PresenceValidator
        builder.set_required(attr.to_s)

      when ActiveModel::Validations::LengthValidator
        if validator.options[:maximum]
          builder.set_max_length(attr.to_s, validator.options[:maximum])
        end
        if validator.options[:minimum]
          builder.set_min_length(attr.to_s, validator.options[:minimum])
        end

      when ActiveModel::Validations::InclusionValidator
        if validator.options[:in].is_a?(Array)
          values = validator.options[:in]
          builder.update_type(attr.to_s, "enum(#{values.join(', ')})")
        end

      when ActiveModel::Validations::NumericalityValidator
        opts = validator.options
        if opts[:greater_than_or_equal_to] && opts[:less_than_or_equal_to]
          min = opts[:greater_than_or_equal_to]
          max = opts[:less_than_or_equal_to]
          builder.update_type(attr.to_s, "number(#{min}..#{max})")
        end
      end
    end
  end
end

.schema_to_dsl(schema) ⇒ Object (private)



316
317
318
319
320
321
322
323
324
325
# File 'lib/synthra/activerecord_inference.rb', line 316

def schema_to_dsl(schema)
  lines = ["#{schema.name}:"]

  schema.fields.each do |field|
    optional_marker = field.optional? ? "?" : ""
    lines << "  #{field.name}#{optional_marker}: #{field.type_name}"
  end

  lines.join("\n")
end