Module: LcpRuby::AssociationFkType

Defined in:
lib/lcp_ruby/association_fk_type.rb

Overview

Shared helpers for resolving foreign-key column types from an AssociationDefinition.

Two flavors are exposed:

  • ‘schema_type(assoc)` — returns a schema-level symbol suitable for `connection.add_column` / `t.<type>` (`:bigint`, `:integer`, `:string`, …). Used by `SchemaManager` when auto-creating FK columns.

  • ‘field_type(assoc)` — returns a string from `Metadata::FieldDefinition::BASE_TYPES` (`“integer”`, `“string”`, …) suitable for constructing a synthetic `FieldDefinition`. Used by `Import::RowProcessor`, `Import::FieldTreeBuilder`, and `Export::FieldTreeBuilder` which build field metadata for FK columns.

The two vocabularies are not the same — ‘:bigint` is a schema concept only and is normalized to `“integer”` for FieldDefinition purposes.

Lives outside SchemaManager so all of the above call sites can reach it without cross-class plumbing.

Constant Summary collapse

SCHEMA_TO_FIELD_TYPE =

Schema-level vocabulary → FieldDefinition::BASE_TYPES vocabulary.

{
  bigint:   "integer",
  integer:  "integer",
  string:   "string",
  text:     "text",
  float:    "float",
  decimal:  "decimal",
  boolean:  "boolean",
  date:     "date",
  datetime: "datetime",
  uuid:     "uuid"
}.freeze
EQUIVALENT_TYPE_PAIRS =

True iff two adapter-level type strings represent the same column storage. Used in ‘validate_managed_field_collisions` to allow legitimate idempotent re-checks (e.g., `:integer` vs `:bigint` for FK columns).

Designed conservatively: silent type widening (string ↔ text, decimal ↔ float) is rejected because the failure mode of a quiet mismatch is data corruption far from the source.

[
  %w[integer bigint].sort.freeze,
  %w[json jsonb].sort.freeze
].to_set.freeze
NON_PG_EQUIVALENT_TYPE_PAIRS =

Adapter-gated equivalents — only compatible on certain adapters. ‘text` ↔ `json` are stored identically on SQLite and MySQL (json is a label over TEXT/LONGTEXT), so a host that declared the column as `t.text` for a YAML `type: array` (json) field is honoring the same storage. PostgreSQL has a real `json`/`jsonb` type — keep strict.

[
  %w[json text].sort.freeze
].to_set.freeze

Class Method Summary collapse

Class Method Details

.adapter_normalized_type(field, connection) ⇒ Object

Normalize a FieldDefinition’s *declared YAML type* to the type the underlying adapter actually stores, so the validator can compare against ‘existing.sql_type_metadata.type` without spurious mismatches.

Reads ‘field.type` (raw declared YAML type) rather than `field.column_type` because `column_type` already collapses some types (`:uuid` → `:string`) before the adapter dimension is known —we want the YAML’s declared shape and apply the adapter mapping here.

Examples (declared YAML type → AR’s ‘sql_type_metadata.type` string):

field type    PG          MySQL       SQLite
──────────────────────────────────────────────
json          jsonb       json        json (AR preserves the type label)
jsonb         jsonb       json        json
uuid          uuid        string      string
enum          string      string      string
rich_text     text        text        text
file          string      string      string
array         <pg array>  text        text
<other base>  <as decl>   <as decl>   <as decl>

Without this normalization, a YAML row like ‘type: json, lcp_managed: true` paired with a host column declared `t.json :settings` reports differently across adapters at the metadata layer (e.g., :jsonb on PG vs :json on SQLite/MySQL) and would spuriously error on every boot.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/lcp_ruby/association_fk_type.rb', line 122

def adapter_normalized_type(field, connection)
  declared = field.type.to_s
  adapter = connection.adapter_name.to_s.downcase

  case declared
  when "json", "jsonb"
    # PG returns :jsonb at the metadata layer for both JSON and JSONB
    # columns; MySQL and SQLite both report :json. The pair {json, jsonb}
    # is treated as compatible by compatible_column_types?, so the
    # normalization here just picks the canonical adapter label.
    adapter.include?("postgres") ? "jsonb" : "json"
  when "uuid"
    adapter.include?("postgres") ? "uuid" : "string"
  when "enum"
    "string"
  when "rich_text"
    "text"
  when "file"
    "string"
  when "array"
    # Mirror SchemaManager#build_column_options: PG uses native typed
    # arrays; non-PG stores via LcpRuby.json_column_type. On SQLite that
    # resolves to :json (AR preserves the type label even though the
    # underlying storage is TEXT); on MySQL to :json. Keeping symmetry
    # with the actual storage label avoids spurious validator collisions
    # when a managed `type: array` field is declared against an existing
    # host column whose AR type is :json.
    adapter.include?("postgres") ? declared : LcpRuby.json_column_type.to_s
  else
    declared
  end
end

.compatible_column_types?(declared, actual, adapter: nil) ⇒ Boolean

‘adapter` is optional — pass `connection.adapter_name` so non-PG storage equivalents can be honored. Without it, only the strict cross-adapter equivalents (EQUIVALENT_TYPE_PAIRS) apply.

Returns:

  • (Boolean)


179
180
181
182
183
184
185
186
187
188
189
# File 'lib/lcp_ruby/association_fk_type.rb', line 179

def compatible_column_types?(declared, actual, adapter: nil)
  d = declared.to_s
  a = actual.to_s
  return true if d == a
  pair = [ d, a ].sort
  return true if EQUIVALENT_TYPE_PAIRS.include?(pair)
  if adapter && !adapter.to_s.downcase.include?("postgres")
    return true if NON_PG_EQUIVALENT_TYPE_PAIRS.include?(pair)
  end
  false
end

.field_type(assoc) ⇒ Object

FieldDefinition-level type string suitable for ‘Metadata::FieldDefinition.new(type: …)`.

Wraps ‘schema_type` and normalizes schema-only aliases (`:bigint` →`“integer”`) onto `FieldDefinition::BASE_TYPES` so the result can be fed into `FieldDefinition.new` without raising.



90
91
92
93
# File 'lib/lcp_ruby/association_fk_type.rb', line 90

def field_type(assoc)
  type = schema_type(assoc)
  SCHEMA_TO_FIELD_TYPE[type.to_sym] || type.to_s
end

.schema_type(assoc) ⇒ Object

Schema-level type symbol suitable for ‘connection.add_column` / `t.<type>`. Implements the resolution rules in the spec’s “FK Column Creation” section. Raises ‘MetadataError` for non-LCP targets with `primary_key:` set (Decision 12) — the validator should have caught this earlier; this is a defensive tripwire so we never silently fall back to `:bigint` and corrupt the schema.



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/lcp_ruby/association_fk_type.rb', line 47

def schema_type(assoc)
  if assoc.primary_key.present? && !assoc.lcp_model?
    raise LcpRuby::MetadataError,
          "Association '#{assoc.owner_name}##{assoc.name}': primary_key " \
          "'#{assoc.primary_key}' targets an external class " \
          "('#{assoc.class_name}'). This should have been caught by " \
          "ConfigurationValidator — please file a bug. Workaround: " \
          "declare the FK column explicitly via 'fields:'."
  end

  return :bigint unless assoc.lcp_model?

  target_def = LcpRuby.loader.model_definitions[assoc.target_model]

  if assoc.primary_key.present?
    unless target_def
      raise LcpRuby::MetadataError,
            "Association '#{assoc.owner_name}##{assoc.name}': target_model " \
            "'#{assoc.target_model}' not found in loader (dangling reference)"
    end

    ref_field = target_def.fields.find { |f| f.name == assoc.primary_key }
    return ref_field.type.to_sym if ref_field

    if target_def.primary_key_column == assoc.primary_key
      return target_def.primary_key_type
    end

    raise LcpRuby::MetadataError,
          "Association '#{assoc.owner_name}##{assoc.name}': primary_key " \
          "'#{assoc.primary_key}' not found on target '#{assoc.target_model}'"
  end

  return :bigint unless target_def
  target_def.primary_key_type
end