Class: Brick::AirtableApiCaller

Inherits:
Object
  • Object
show all
Extended by:
FancyGets
Defined in:
lib/generators/brick/airtable_api_caller.rb

Defined Under Namespace

Classes: AirtableTable

Class Method Summary collapse

Class Method Details

.https_get(uri, pat = nil) ⇒ Object



143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/generators/brick/airtable_api_caller.rb', line 143

def https_get(uri, pat = nil)
  uri = URI(uri) unless uri.is_a?(URI)
  https = Net::HTTP.new(uri.host, uri.port)
  request = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
  request['Authorization'] = "Bearer #{@bearer ||= pat}"
  response = Net::HTTP.start(uri.hostname, uri.port, { use_ssl: true }) do |http|
    http.request(request)
  end
  # if response.code&.to_i > 299
  # end
  JSON.parse(response.body)
end

.pick_tables(is_airtable_id, usage = :migrations) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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
138
139
140
141
# File 'lib/generators/brick/airtable_api_caller.rb', line 8

def pick_tables(is_airtable_id, usage = :migrations)
  puts "In order to reference Airtable data you will need a Personal Access Token (PAT) which can be generated by referencing this URL:
  https://airtable.com/create/tokens
You need only #{usage == :migrations ? 'this scope:' : "these three scopes:
  data.records:read
  data.recordComments:read"}
  schema.bases:read

Please provide your Airtable PAT:"
  pat = gets_password
  pat = ENV['AIRTABLE_PAT'] if pat.blank?
  require 'net/http'
  # Generate a list of bases that can be chosen
  bases = https_get('https://api.airtable.com/v0/meta/bases', pat)
  if (error = bases.fetch('error', nil))
    puts error.fetch('message', nil) || 'Error'
    return
  end
  base = gets_list(bases.fetch('bases', nil)&.map { |z| AirtableTable.new(z['id'], z['name']) })
  puts
  # Generate a list of tables that can be chosen
  objects = https_get("https://api.airtable.com/v0/meta/bases/#{base.id}/tables", pat).fetch('tables', nil)
  if objects.blank?
    puts "No tables found in base #{base.name}."
    return
  end

  tables = objects.map { |z| AirtableTable.new(z['id'], z['name'], z['primaryFieldId'], z['fields'], z['views'], base.id) }
  chosen = gets_list(tables, tables.dup)
  puts

  # Build out a '::Brick.relations' hash that represents this Airtable schema
  fks = []
  associatives = {}
  relations = chosen.each_with_object({base_name: base.name}) do |table, s|
                tbl_name = sane_table_name(table.name)
                # Build out columns and foreign keys
                cols = {}
                col_order = []
                table.fields.each do |col|
                  col_name = sane_name(col['name'])
                  # This is like a has_many or has_many through
                  if col['type'] == 'multipleRecordLinks'
                    # binding.pry if col['options']['isReversed']
                    if (frn_tbl = sane_table_name(
                                    chosen.find { |t| t.id == col['options']['linkedTableId'] }&.name
                                  ))
                      if col['options']['prefersSingleRecordLink'] # 1:M
                        fks << [frn_tbl, (fk_col_name = "#{col_name}_id"), tbl_name, col_name]
                        col_order << fk_col_name
                      else # N:M
                        # Queue up to build associative table with two foreign keys
                        camelized = (assoc_name = "#{tbl_name}_#{col_name}_#{frn_tbl}").camelize
                        if associatives.keys.any? { |a| a.camelize == camelized }
                          puts "Strangely have found two columns in \"#{table.name}\" with a name similar to \"#{col_name}\".  Skipping one to avoid a conflict."
                          next

                        end
                        associatives[assoc_name] = [col_name, frn_tbl, tbl_name]
                        fks << [assoc_name, frn_tbl, tbl_name, col_name.underscore, tbl_name]
                      end
                    end
                  else
                    # puts col['type']
                    dt = case col['type']
                    when 'singleLineText', 'url', 'email', 'singleSelect', 'phoneNumber'
                      'string'
                    when 'multilineText'
                      'text'
                    when 'number', 'currency'
                      col['options']['precision'] == 0 ? 'integer' : 'decimal'
                    when 'checkbox'
                      'boolean'
                    when 'date'
                      'date'
                    when 'multipleSelects', 'singleCollaborator'
                      # Sqlite3 can do json
                      'json'
                    when 'formula', 'count', 'rollup', 'multipleAttachments'
                      next
                    # else
                    #   binding.pry
                    end
                    cols[col_name] = [dt, nil, true, false] # true is the col[:nillable]
                    col_order << col_name
                  end
                end
                # Put it all into a relation entry, named the same as the table
                pkey = if is_airtable_id
                         cols['airtable_id'] = ['string', nil, true, false]
                         'airtable_id'
                       else
                         table.fields.find { |f| f['id'] == table.primary_key }['name']
                       end

                s[tbl_name] = {
                  pkey: { "#{tbl_name}_pkey" => [sane_name(pkey)] },
                  cols: cols,
                  col_order: col_order,
                  fks: {},
                  airtable_table: table
                }
              end
  associatives.each do |k, v|
    pri_fk_name = "#{v[1]}_id"
    frn_fk_base = (v[1] == v[2]) ? "#{v[2]}_2" : v[2] # Accommodates self-referencing N:Ms
    frn_fk_name = "#{frn_fk_base}_id"
    relations[k] = {
      pkey: { "#{k}_pkey" => ['id'] },
      cols: { 'id' => ['integer', nil, false, false] }
    }
    fks << [v[1], pri_fk_name, k, v[0].underscore]
    fks << [v[2], frn_fk_name, k, frn_fk_base.underscore]
  end
  fk_idx = 0
  fks.each do |pri_tbl, fk_col, frn_tbl, airtable_col, assoc_tbl|
    pri_pk_col = relations[pri_tbl][:pkey].first.last.first
    # binding.pry unless relations.key?(frn_tbl) && relations[pri_tbl][:cols][pri_pk_col]
    unless assoc_tbl # It's a 1:M -- make a FK column
      relations[frn_tbl][:cols][fk_col] = [relations[pri_tbl][:cols][pri_pk_col][0], nil, true, false]
    end
    # And the actual relation
    frn_fks = ((relations[frn_tbl] ||= {})[:fks] ||= {})
    this_fk = frn_fks["fk_airtable_#{fk_idx += 1}"] = {
      is_bt: !assoc_tbl, # Normal foreign key is true, and N:M is really a has_many, so false
      fk: fk_col,
      assoc_name: airtable_col,
      inverse_table: pri_tbl
    }
    this_fk[:assoc_tbl] = assoc_tbl if assoc_tbl
  end

  relations
end

.sane_name(col_name) ⇒ Object



156
157
158
# File 'lib/generators/brick/airtable_api_caller.rb', line 156

def sane_name(col_name)
  sane_table_name(col_name.gsub('&', 'and').tr('()?', '').tr('/', '_'))
end

.sane_table_name(tbl_name) ⇒ Object



160
161
162
# File 'lib/generators/brick/airtable_api_caller.rb', line 160

def sane_table_name(tbl_name)
  tbl_name&.downcase&.tr(': -', '_')
end