overture_maps 0.1.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/CHANGELOG.md +29 -0
- data/LICENSE.txt +21 -0
- data/README.md +416 -0
- data/app/controllers/overture_maps/application_controller.rb +28 -0
- data/app/controllers/overture_maps/attribution_controller.rb +15 -0
- data/app/controllers/overture_maps/features_controller.rb +125 -0
- data/app/controllers/overture_maps/search_controller.rb +31 -0
- data/app/controllers/overture_maps/tiles_controller.rb +85 -0
- data/config/routes.rb +13 -0
- data/exe/overture-maps-mcp +6 -0
- data/lib/generators/overture_maps/address_generator.rb +19 -0
- data/lib/generators/overture_maps/base_features_generator.rb +19 -0
- data/lib/generators/overture_maps/base_generator.rb +22 -0
- data/lib/generators/overture_maps/building_generator.rb +19 -0
- data/lib/generators/overture_maps/division_generator.rb +19 -0
- data/lib/generators/overture_maps/install_generator.rb +42 -0
- data/lib/generators/overture_maps/place_generator.rb +19 -0
- data/lib/generators/overture_maps/transportation_generator.rb +20 -0
- data/lib/generators/templates/address_migration.rb.tt +25 -0
- data/lib/generators/templates/address_model.rb.tt +4 -0
- data/lib/generators/templates/base_feature_model.rb.tt +4 -0
- data/lib/generators/templates/base_features_migration.rb.tt +27 -0
- data/lib/generators/templates/building_migration.rb.tt +24 -0
- data/lib/generators/templates/building_model.rb.tt +4 -0
- data/lib/generators/templates/category_migration.rb.tt +15 -0
- data/lib/generators/templates/category_model.rb.tt +4 -0
- data/lib/generators/templates/connector_model.rb.tt +4 -0
- data/lib/generators/templates/division_migration.rb.tt +31 -0
- data/lib/generators/templates/division_model.rb.tt +4 -0
- data/lib/generators/templates/imported_areas_migration.rb.tt +20 -0
- data/lib/generators/templates/place_migration.rb.tt +30 -0
- data/lib/generators/templates/place_model.rb.tt +4 -0
- data/lib/generators/templates/postgis_migration.rb.tt +5 -0
- data/lib/generators/templates/segment_model.rb.tt +4 -0
- data/lib/generators/templates/transportation_migration.rb.tt +38 -0
- data/lib/overture_maps/attribution.rb +66 -0
- data/lib/overture_maps/bounding_box.rb +80 -0
- data/lib/overture_maps/changelog.rb +70 -0
- data/lib/overture_maps/configuration.rb +43 -0
- data/lib/overture_maps/database.rb +74 -0
- data/lib/overture_maps/division_search.rb +44 -0
- data/lib/overture_maps/engine.rb +32 -0
- data/lib/overture_maps/geometry_parser.rb +41 -0
- data/lib/overture_maps/gers.rb +30 -0
- data/lib/overture_maps/import/downloader.rb +248 -0
- data/lib/overture_maps/import/location_based_runner.rb +176 -0
- data/lib/overture_maps/import/parquet_reader.rb +32 -0
- data/lib/overture_maps/import/record_mapper.rb +182 -0
- data/lib/overture_maps/import/runner.rb +125 -0
- data/lib/overture_maps/import.rb +27 -0
- data/lib/overture_maps/mcp_server.rb +222 -0
- data/lib/overture_maps/models/address.rb +32 -0
- data/lib/overture_maps/models/base.rb +39 -0
- data/lib/overture_maps/models/base_feature.rb +31 -0
- data/lib/overture_maps/models/building.rb +35 -0
- data/lib/overture_maps/models/category.rb +32 -0
- data/lib/overture_maps/models/connector.rb +11 -0
- data/lib/overture_maps/models/division.rb +43 -0
- data/lib/overture_maps/models/imported_area.rb +42 -0
- data/lib/overture_maps/models/place.rb +43 -0
- data/lib/overture_maps/models/segment.rb +22 -0
- data/lib/overture_maps/models.rb +25 -0
- data/lib/overture_maps/query.rb +168 -0
- data/lib/overture_maps/query_engine.rb +268 -0
- data/lib/overture_maps/releases.rb +74 -0
- data/lib/overture_maps/storage.rb +121 -0
- data/lib/overture_maps/syncer.rb +99 -0
- data/lib/overture_maps/util.rb +30 -0
- data/lib/overture_maps/version.rb +3 -0
- data/lib/overture_maps.rb +42 -0
- data/lib/tasks/overture_maps.rake +554 -0
- metadata +301 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OvertureMaps
|
|
4
|
+
module Import
|
|
5
|
+
# Orchestrates a location-based import: resolves the location (bbox
|
|
6
|
+
# string or division name) to a bounding box, downloads or reuses a
|
|
7
|
+
# bbox-filtered parquet extract per feature type, and streams it into the
|
|
8
|
+
# target model via Runner.
|
|
9
|
+
#
|
|
10
|
+
# Themes map one or more feature types to models:
|
|
11
|
+
# models: { "segment" => OvertureSegment, "connector" => OvertureConnector }
|
|
12
|
+
# For single-model themes, model_class: alone is enough.
|
|
13
|
+
#
|
|
14
|
+
# This class never prompts or exits. Interactive behavior is injected by
|
|
15
|
+
# the rake layer through callbacks:
|
|
16
|
+
# select_division: ->(results) { result or nil } nil aborts
|
|
17
|
+
# confirm_cached: ->(path) { :use | :refresh | :abort }
|
|
18
|
+
# Without callbacks (library/job usage) it picks the largest matching
|
|
19
|
+
# division and reuses exact cache hits.
|
|
20
|
+
class LocationBasedRunner
|
|
21
|
+
attr_reader :theme, :location, :imported_count, :error_count, :errors, :bbox
|
|
22
|
+
|
|
23
|
+
def initialize(theme:, location:, model_class: nil, models: nil, categories: nil,
|
|
24
|
+
batch_size: nil, release: nil, output_dir: nil,
|
|
25
|
+
select_division: nil, confirm_cached: nil)
|
|
26
|
+
@theme = theme
|
|
27
|
+
@location = location
|
|
28
|
+
@models = resolve_models(model_class, models)
|
|
29
|
+
@categories = categories
|
|
30
|
+
@batch_size = batch_size
|
|
31
|
+
@release = Releases.validate!(release || Releases.current)
|
|
32
|
+
@output_dir = output_dir
|
|
33
|
+
@select_division = select_division
|
|
34
|
+
@confirm_cached = confirm_cached
|
|
35
|
+
@imported_count = 0
|
|
36
|
+
@error_count = 0
|
|
37
|
+
@errors = []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
@bbox = resolve_bbox
|
|
42
|
+
log "Bounding box: #{bbox} (release #{@release})"
|
|
43
|
+
|
|
44
|
+
@models.each { |type, model_class| import_type(type, model_class) }
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def success?
|
|
49
|
+
@error_count.zero?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def resolve_models(model_class, models)
|
|
55
|
+
return models if models&.any?
|
|
56
|
+
raise ArgumentError, "provide model_class: or models:" unless model_class
|
|
57
|
+
|
|
58
|
+
types = Downloader.types_for_theme(theme)
|
|
59
|
+
raise Error, "no types for theme: #{theme}" if types.empty?
|
|
60
|
+
|
|
61
|
+
types.to_h { |t| [t, model_class] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolve_bbox
|
|
65
|
+
return location if location.is_a?(BoundingBox)
|
|
66
|
+
|
|
67
|
+
parsed = BoundingBox.parse(location)
|
|
68
|
+
return parsed if parsed
|
|
69
|
+
|
|
70
|
+
results = DivisionSearch.search(query: location, release: @release)
|
|
71
|
+
raise Error, "no divisions found matching #{location.inspect}" if results.empty?
|
|
72
|
+
|
|
73
|
+
division = choose_division(results)
|
|
74
|
+
raise CancelledError, "import cancelled" unless division
|
|
75
|
+
|
|
76
|
+
log "Using #{division[:name]} (#{division[:subtype]}, #{[division[:country], division[:region]].compact.join(" / ")})"
|
|
77
|
+
division[:bbox]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def choose_division(results)
|
|
81
|
+
return results.first if results.length == 1 || @select_division.nil?
|
|
82
|
+
|
|
83
|
+
@select_division.call(results)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def import_type(type, model_class)
|
|
87
|
+
downloader = Downloader.new(theme: theme, type: type, release: @release, output_dir: @output_dir)
|
|
88
|
+
path = resolve_extract(downloader)
|
|
89
|
+
unless path
|
|
90
|
+
log "#{theme}/#{type}: no data found"
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
runner = Runner.new(model_class: model_class, theme: theme, type: type,
|
|
95
|
+
batch_size: @batch_size, release: @release)
|
|
96
|
+
reader = ParquetReader.new(theme: theme)
|
|
97
|
+
log "#{theme}/#{type}: importing #{File.basename(path)}..."
|
|
98
|
+
runner.import_from_reader(reader, source: path, filter: category_filter)
|
|
99
|
+
|
|
100
|
+
@imported_count += runner.imported_count
|
|
101
|
+
@error_count += runner.error_count
|
|
102
|
+
@errors.concat(runner.errors)
|
|
103
|
+
log "#{theme}/#{type}: imported #{runner.imported_count}, errors #{runner.error_count}"
|
|
104
|
+
|
|
105
|
+
record_imported_area(type, model_class, runner)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Bookkeeping for overture_maps:sync. Skipped silently when the
|
|
109
|
+
# tracking table hasn't been migrated yet.
|
|
110
|
+
def record_imported_area(type, model_class, runner)
|
|
111
|
+
return unless runner.imported_count.positive?
|
|
112
|
+
return unless Models::ImportedArea.table_exists?
|
|
113
|
+
|
|
114
|
+
Models::ImportedArea.record!(
|
|
115
|
+
theme: theme, feature_type: type, model_class: model_class,
|
|
116
|
+
bbox: bbox, release: @release,
|
|
117
|
+
records_count: runner.imported_count
|
|
118
|
+
)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
log "sync tracking skipped: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def resolve_extract(downloader)
|
|
124
|
+
cached = downloader.cached_extract(bbox)
|
|
125
|
+
|
|
126
|
+
if cached
|
|
127
|
+
case cached_decision(cached)
|
|
128
|
+
when :use
|
|
129
|
+
log "Reusing cached extract #{File.basename(cached)}"
|
|
130
|
+
return cached
|
|
131
|
+
when :abort
|
|
132
|
+
raise CancelledError, "import cancelled"
|
|
133
|
+
else
|
|
134
|
+
File.delete(cached)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
downloader.extract_bbox(bbox)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def cached_decision(path)
|
|
142
|
+
return :use unless @confirm_cached
|
|
143
|
+
|
|
144
|
+
@confirm_cached.call(path)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Matches leaf categories against categories.primary and
|
|
148
|
+
# categories.alternate. Taxonomy groups (e.g. "eat_and_drink",
|
|
149
|
+
# "restaurant") expand to their leaves when the categories table has
|
|
150
|
+
# been populated (rails overture_maps:categories:populate).
|
|
151
|
+
def category_filter
|
|
152
|
+
return nil unless @categories&.any?
|
|
153
|
+
|
|
154
|
+
wanted = expand_categories(@categories.map(&:to_s))
|
|
155
|
+
lambda do |record|
|
|
156
|
+
cats = record["categories"]
|
|
157
|
+
primary = cats.is_a?(Hash) ? cats["primary"] : record["basic_category"]
|
|
158
|
+
alternates = cats.is_a?(Hash) ? Array(cats["alternate"]) : []
|
|
159
|
+
|
|
160
|
+
wanted.any? { |w| primary == w || alternates.include?(w) }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def expand_categories(names)
|
|
165
|
+
Models::Category.expand(names)
|
|
166
|
+
rescue StandardError
|
|
167
|
+
names
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def log(message)
|
|
171
|
+
logger = OvertureMaps.configuration.logger
|
|
172
|
+
logger ? logger.info(message) : puts(message)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parquet"
|
|
4
|
+
|
|
5
|
+
module OvertureMaps
|
|
6
|
+
module Import
|
|
7
|
+
# Reads local Overture parquet files row by row. Remote access and
|
|
8
|
+
# spatial filtering live in Downloader/QueryEngine — by the time a file
|
|
9
|
+
# reaches this class it is already a local extract.
|
|
10
|
+
class ParquetReader
|
|
11
|
+
THEMES = Downloader::THEMES
|
|
12
|
+
|
|
13
|
+
attr_reader :theme
|
|
14
|
+
|
|
15
|
+
def initialize(theme: nil)
|
|
16
|
+
@theme = theme
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def each_record(source:, &block)
|
|
20
|
+
raise ArgumentError, "source must be a local file path" unless source.is_a?(String) && File.exist?(source)
|
|
21
|
+
|
|
22
|
+
Parquet.each_row(source, &block)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def record_count(source:)
|
|
26
|
+
raise ArgumentError, "source must be a local file path" unless source.is_a?(String) && File.exist?(source)
|
|
27
|
+
|
|
28
|
+
Parquet.metadata(source)["num_rows"]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OvertureMaps
|
|
4
|
+
module Import
|
|
5
|
+
# Maps raw Overture records (nested structs from parquet) onto flat model
|
|
6
|
+
# attributes. Every record gets the same key set — heterogeneous keys make
|
|
7
|
+
# ActiveRecord's insert_all/upsert_all reject the whole batch.
|
|
8
|
+
class RecordMapper
|
|
9
|
+
def self.for(theme:, model_class:, type: nil, release: nil)
|
|
10
|
+
new(theme: theme, model_class: model_class, type: type, release: release)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :theme, :type, :model_class, :release
|
|
14
|
+
|
|
15
|
+
def initialize(theme:, model_class:, type: nil, release: nil)
|
|
16
|
+
@theme = theme
|
|
17
|
+
@type = type
|
|
18
|
+
@model_class = model_class
|
|
19
|
+
@release = release
|
|
20
|
+
@columns = model_class.column_names
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns attributes with a stable key set, or nil if the record can't
|
|
24
|
+
# be mapped (caller records the error).
|
|
25
|
+
def call(record)
|
|
26
|
+
attrs = base_attributes(record)
|
|
27
|
+
attrs.merge!(theme_attributes(record))
|
|
28
|
+
normalize(attrs)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The attribute keys every mapped record will contain.
|
|
32
|
+
def key_set
|
|
33
|
+
@key_set ||= begin
|
|
34
|
+
sample = base_attributes({}).merge(theme_attributes({}))
|
|
35
|
+
keys = sample.keys.select { |k| @columns.include?(k.to_s) }
|
|
36
|
+
keys | %i[id geometry]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def base_attributes(record)
|
|
43
|
+
{
|
|
44
|
+
id: record["id"],
|
|
45
|
+
geometry: record["geometry"],
|
|
46
|
+
names: record["names"],
|
|
47
|
+
name: record.dig("names", "primary"),
|
|
48
|
+
sources: record["sources"],
|
|
49
|
+
overture_release: release,
|
|
50
|
+
created_at: now,
|
|
51
|
+
updated_at: now
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def theme_attributes(record)
|
|
56
|
+
case theme
|
|
57
|
+
when "places" then place_attributes(record)
|
|
58
|
+
when "buildings" then building_attributes(record)
|
|
59
|
+
when "addresses" then address_attributes(record)
|
|
60
|
+
when "divisions" then division_attributes(record)
|
|
61
|
+
when "transportation" then transportation_attributes(record)
|
|
62
|
+
when "base" then base_theme_attributes(record)
|
|
63
|
+
else {}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def place_attributes(record)
|
|
68
|
+
{
|
|
69
|
+
# categories is deprecated in favor of basic_category + taxonomy
|
|
70
|
+
# (~Sep 2026); read both shapes so imports survive the cutover.
|
|
71
|
+
categories: record["categories"] || record["taxonomy"],
|
|
72
|
+
primary_category: record.dig("categories", "primary") || record["basic_category"],
|
|
73
|
+
brands: record["brand"],
|
|
74
|
+
addresses: record["addresses"],
|
|
75
|
+
confidence: record["confidence"],
|
|
76
|
+
operating_status: record["operating_status"],
|
|
77
|
+
websites: record["websites"],
|
|
78
|
+
socials: record["socials"],
|
|
79
|
+
emails: record["emails"],
|
|
80
|
+
phones: record["phones"],
|
|
81
|
+
country: first_address_value(record, "country")
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def building_attributes(record)
|
|
86
|
+
{
|
|
87
|
+
subtype: record["subtype"],
|
|
88
|
+
building_class: record["class"],
|
|
89
|
+
height: record["height"],
|
|
90
|
+
num_floors: record["num_floors"],
|
|
91
|
+
level: record["level"],
|
|
92
|
+
is_underground: record["is_underground"]
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def address_attributes(record)
|
|
97
|
+
levels = Array(record["address_levels"])
|
|
98
|
+
{
|
|
99
|
+
number: record["number"],
|
|
100
|
+
street: record["street"],
|
|
101
|
+
unit: record["unit"],
|
|
102
|
+
postcode: record["postcode"],
|
|
103
|
+
country: record["country"],
|
|
104
|
+
postal_city: record["postal_city"],
|
|
105
|
+
address_levels: record["address_levels"],
|
|
106
|
+
# Best-effort convenience columns; ordering of address_levels is
|
|
107
|
+
# country-dependent, the authoritative data is the jsonb column.
|
|
108
|
+
region: levels.dig(0, "value"),
|
|
109
|
+
locality: record["postal_city"] || levels.dig(1, "value")
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# division_area records: the geocodable territory of a division.
|
|
114
|
+
def division_attributes(record)
|
|
115
|
+
bbox = record["bbox"] || {}
|
|
116
|
+
{
|
|
117
|
+
division_id: record["division_id"],
|
|
118
|
+
subtype: record["subtype"],
|
|
119
|
+
division_class: record["class"],
|
|
120
|
+
country: record["country"],
|
|
121
|
+
region: record["region"],
|
|
122
|
+
is_land: record["is_land"],
|
|
123
|
+
is_territorial: record["is_territorial"],
|
|
124
|
+
bbox_xmin: bbox["xmin"],
|
|
125
|
+
bbox_xmax: bbox["xmax"],
|
|
126
|
+
bbox_ymin: bbox["ymin"],
|
|
127
|
+
bbox_ymax: bbox["ymax"]
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def transportation_attributes(record)
|
|
132
|
+
return {} unless type == "segment" # connectors carry only id + geometry
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
subtype: record["subtype"],
|
|
136
|
+
segment_class: record["class"],
|
|
137
|
+
subclass: record["subclass"],
|
|
138
|
+
connectors: record["connectors"],
|
|
139
|
+
routes: record["routes"],
|
|
140
|
+
speed_limits: record["speed_limits"],
|
|
141
|
+
access_restrictions: record["access_restrictions"],
|
|
142
|
+
prohibited_transitions: record["prohibited_transitions"],
|
|
143
|
+
road_surface: record["road_surface"],
|
|
144
|
+
road_flags: record["road_flags"],
|
|
145
|
+
destinations: record["destinations"]
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def base_theme_attributes(record)
|
|
150
|
+
{
|
|
151
|
+
feature_type: type,
|
|
152
|
+
subtype: record["subtype"],
|
|
153
|
+
feature_class: record["class"],
|
|
154
|
+
surface: record["surface"],
|
|
155
|
+
elevation: record["elevation"],
|
|
156
|
+
level: record["level"],
|
|
157
|
+
wikidata: record["wikidata"],
|
|
158
|
+
source_tags: record["source_tags"]
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def first_address_value(record, key)
|
|
163
|
+
addresses = record["addresses"]
|
|
164
|
+
return nil unless addresses.is_a?(Array) && addresses.first.is_a?(Hash)
|
|
165
|
+
|
|
166
|
+
addresses.first[key]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Keep only real columns, ensure every key from the key set is present
|
|
170
|
+
# so batches stay homogeneous.
|
|
171
|
+
def normalize(attrs)
|
|
172
|
+
normalized = {}
|
|
173
|
+
key_set.each { |key| normalized[key] = attrs[key] }
|
|
174
|
+
normalized
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def now
|
|
178
|
+
@now ||= Time.now.utc
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rgeo"
|
|
4
|
+
require "rgeo/geo_json"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module OvertureMaps
|
|
8
|
+
module Import
|
|
9
|
+
# Batches records into idempotent upserts. Re-running an import updates
|
|
10
|
+
# existing rows (keyed on the GERS id primary key) instead of failing.
|
|
11
|
+
class Runner
|
|
12
|
+
MAX_STORED_ERRORS = 50
|
|
13
|
+
PROGRESS_INTERVAL = 10_000
|
|
14
|
+
|
|
15
|
+
attr_reader :model_class, :theme, :type, :batch_size, :imported_count, :error_count, :errors
|
|
16
|
+
|
|
17
|
+
def initialize(model_class:, theme: nil, type: nil, batch_size: nil, mapper: nil,
|
|
18
|
+
release: nil, progress_every: PROGRESS_INTERVAL)
|
|
19
|
+
@model_class = model_class
|
|
20
|
+
@theme = theme
|
|
21
|
+
@type = type
|
|
22
|
+
@release = release
|
|
23
|
+
@batch_size = batch_size || OvertureMaps.configuration.batch_size
|
|
24
|
+
@mapper = mapper
|
|
25
|
+
@progress_every = progress_every
|
|
26
|
+
@imported_count = 0
|
|
27
|
+
@error_count = 0
|
|
28
|
+
@errors = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Imports from anything that yields raw Overture record hashes.
|
|
32
|
+
def import_from_records(records, transform: nil, filter: nil)
|
|
33
|
+
batch = []
|
|
34
|
+
|
|
35
|
+
records.each do |record|
|
|
36
|
+
next if filter && !filter.call(record)
|
|
37
|
+
|
|
38
|
+
attrs = transform_record(record, transform)
|
|
39
|
+
next unless attrs
|
|
40
|
+
|
|
41
|
+
batch << attrs
|
|
42
|
+
if batch.length >= batch_size
|
|
43
|
+
flush_records(batch)
|
|
44
|
+
batch = []
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
flush_records(batch) if batch.any?
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def import_from_reader(reader, source:, transform: nil, filter: nil)
|
|
53
|
+
import_from_records(reader.enum_for(:each_record, source: source), transform: transform, filter: filter)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def import_from_file(path, theme: nil, transform: nil, filter: nil)
|
|
57
|
+
reader = ParquetReader.new(theme: theme)
|
|
58
|
+
import_from_reader(reader, source: path, transform: transform, filter: filter)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def success?
|
|
62
|
+
@error_count.zero?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def transform_record(record, transform)
|
|
68
|
+
attrs = transform ? transform.call(record) : default_mapper.call(record)
|
|
69
|
+
return nil unless attrs
|
|
70
|
+
|
|
71
|
+
if attrs[:geometry] || attrs["geometry"]
|
|
72
|
+
key = attrs.key?(:geometry) ? :geometry : "geometry"
|
|
73
|
+
attrs[key] = parse_geometry(attrs[key])
|
|
74
|
+
end
|
|
75
|
+
attrs
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
record_error("mapping failed: #{e.message}", record["id"])
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def default_mapper
|
|
82
|
+
@default_mapper ||= @mapper || RecordMapper.for(
|
|
83
|
+
theme: theme, type: type, model_class: model_class, release: @release
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def flush_records(records)
|
|
88
|
+
return if records.empty?
|
|
89
|
+
|
|
90
|
+
model_class.upsert_all(records, unique_by: model_class.primary_key)
|
|
91
|
+
@imported_count += records.length
|
|
92
|
+
log_progress(records.length)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
# Isolate bad rows without abandoning the batch.
|
|
95
|
+
records.each do |record|
|
|
96
|
+
model_class.upsert_all([record], unique_by: model_class.primary_key)
|
|
97
|
+
@imported_count += 1
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
record_error(e.message, record[:id] || record["id"])
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def record_error(message, record_id = nil)
|
|
104
|
+
@error_count += 1
|
|
105
|
+
@errors << { error: message, record_id: record_id } if @errors.length < MAX_STORED_ERRORS
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A line every @progress_every rows so long imports show life.
|
|
109
|
+
def log_progress(batch_length)
|
|
110
|
+
return unless @progress_every&.positive?
|
|
111
|
+
return unless (@imported_count / @progress_every) > ((@imported_count - batch_length) / @progress_every)
|
|
112
|
+
|
|
113
|
+
logger = OvertureMaps.configuration.logger
|
|
114
|
+
message = " #{@imported_count} rows..."
|
|
115
|
+
logger ? logger.info(message) : puts(message)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A geometry that fails to parse skips that record — it must never
|
|
119
|
+
# abort the import loop (see transform_record's rescue).
|
|
120
|
+
def parse_geometry(geom)
|
|
121
|
+
GeometryParser.parse(geom)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OvertureMaps
|
|
4
|
+
module Import
|
|
5
|
+
class Error < OvertureMaps::Error; end
|
|
6
|
+
end
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
require "overture_maps/import/downloader"
|
|
10
|
+
require "overture_maps/import/parquet_reader"
|
|
11
|
+
require "overture_maps/import/record_mapper"
|
|
12
|
+
require "overture_maps/import/runner"
|
|
13
|
+
require "overture_maps/import/location_based_runner"
|
|
14
|
+
|
|
15
|
+
module OvertureMaps
|
|
16
|
+
module Import
|
|
17
|
+
# Programmatic file import. The transform may be given as a keyword or a
|
|
18
|
+
# block; it receives each raw record and returns attributes (or nil to
|
|
19
|
+
# skip the record).
|
|
20
|
+
def self.run!(theme:, model_class:, file_path:, batch_size: nil, transform: nil, release: nil, &block)
|
|
21
|
+
transform ||= block
|
|
22
|
+
runner = Runner.new(model_class: model_class, theme: theme, batch_size: batch_size, release: release)
|
|
23
|
+
runner.import_from_file(file_path, theme: theme, transform: transform)
|
|
24
|
+
runner
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|