tg_geometry 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +55 -79
- data/README.md +168 -184
- data/Rakefile +3 -3
- data/benchmark/ewkb_roundtrip.rb +29 -0
- data/benchmark/falcon_concurrency.rb +1 -1
- data/benchmark/feature_source.rb +92 -0
- data/benchmark/geom_query.rb +33 -0
- data/benchmark/nearest_segment.rb +20 -0
- data/docs/ARCHITECTURE.md +29 -107
- data/docs/BENCHMARKING.md +20 -1
- data/docs/CASUAL_EXAMPLE.md +71 -458
- data/docs/CONCURRENCY.md +13 -7
- data/docs/ERROR_HANDLING.md +30 -0
- data/docs/FEATURE_SOURCE.md +166 -0
- data/docs/GEOMETRY_QUERIES.md +23 -0
- data/docs/LIMITATIONS.md +17 -54
- data/docs/MEMORY_OWNERSHIP.md +20 -2
- data/docs/NEAREST_SEGMENT.md +17 -0
- data/docs/SRID_AND_EWKB.md +23 -0
- data/ext/tg_geometry/extconf.rb +46 -4
- data/ext/tg_geometry/tg_geometry_ext.c +3643 -168
- data/ext/tg_geometry/tg_geometry_vendor_json.c +17 -0
- data/ext/tg_geometry/tg_geometry_vendor_tg.c +3 -0
- data/ext/tg_geometry/vendor/.vendored +8 -2
- data/ext/tg_geometry/vendor/json/LICENSE +20 -0
- data/ext/tg_geometry/vendor/json/VERSION +3 -0
- data/ext/tg_geometry/vendor/json/json.c +1024 -0
- data/ext/tg_geometry/vendor/json/json.h +207 -0
- data/lib/tg/geometry/active_record_source.rb +16 -34
- data/lib/tg/geometry/active_record_type.rb +61 -0
- data/lib/tg/geometry/registry.rb +16 -67
- data/lib/tg/geometry/version.rb +1 -1
- data/lib/tg/geometry.rb +85 -0
- data/script/vendor_libs.rb +22 -6
- data/spec/active_record_type_spec.rb +45 -0
- data/spec/{expansion_a_auto_strategy_spec.rb → auto_strategy_spec.rb} +1 -1
- data/spec/{block_12_batch_packed_spec.rb → batch_packed_spec.rb} +1 -1
- data/spec/{block_20_concurrency_spec.rb → concurrency_spec.rb} +1 -1
- data/spec/constructors_spec.rb +104 -0
- data/spec/{block_13_error_hardening_spec.rb → error_hardening_spec.rb} +1 -1
- data/spec/feature_source_nogvl_spec.rb +51 -0
- data/spec/feature_source_spec.rb +268 -0
- data/spec/fixtures/feature_source/invalid_geometry_middle.geojson +8 -0
- data/spec/fixtures/feature_source/malformed_json.geojson +1 -0
- data/spec/fixtures/feature_source/mixed_geometry_types.geojson +8 -0
- data/spec/fixtures/feature_source/osm_like_feature_collection.geojson +10 -0
- data/spec/fixtures/feature_source/properties_null_missing.geojson +7 -0
- data/spec/fixtures/feature_source/simple_feature_collection.geojson +15 -0
- data/spec/fixtures/postgis/README.md +16 -0
- data/spec/fixtures/postgis/boundary_point_cases.geojson +83 -0
- data/spec/fixtures/postgis/multipolygon_large.ewkb +0 -0
- data/spec/fixtures/postgis/point_4326.ewkb +0 -0
- data/spec/fixtures/postgis/polygon_3857.ewkb +0 -0
- data/spec/fixtures/postgis/polygon_4326_simple.ewkb +0 -0
- data/spec/fixtures/postgis/polygon_4326_with_hole.ewkb +0 -0
- data/spec/{expansion_d_format_coverage_spec.rb → format_coverage_spec.rb} +1 -1
- data/spec/{block_20_fuzz_spec.rb → fuzz_spec.rb} +1 -1
- data/spec/{block_4_geom_api_spec.rb → geom_api_spec.rb} +1 -1
- data/spec/{block_3_geom_parse_spec.rb → geom_parse_spec.rb} +1 -1
- data/spec/{block_8_index_borrowed_geometry_spec.rb → index_borrowed_geometry_spec.rb} +1 -1
- data/spec/{block_6_index_build_spec.rb → index_build_spec.rb} +2 -2
- data/spec/{block_9_flat_query_spec.rb → index_flat_query_spec.rb} +1 -1
- data/spec/index_geom_query_spec.rb +68 -0
- data/spec/{block_7_index_owned_geometry_spec.rb → index_owned_geometry_spec.rb} +1 -1
- data/spec/{block_10_rtree_strategy_spec.rb → index_rtree_accounting_spec.rb} +1 -1
- data/spec/{block_11_rtree_order_spec.rb → index_rtree_order_spec.rb} +1 -1
- data/spec/keyword_validation_spec.rb +31 -0
- data/spec/{block_1_skeleton_spec.rb → load_and_errors_spec.rb} +1 -1
- data/spec/{expansion_e_low_level_geometry_spec.rb → low_level_geometry_spec.rb} +1 -1
- data/spec/{block_14_memory_gc_hardening_spec.rb → memory_gc_spec.rb} +1 -1
- data/spec/nearest_segment_spec.rb +62 -0
- data/spec/postgis_fixtures_spec.rb +68 -0
- data/spec/{expansion_i_ractor_spec.rb → ractor_spec.rb} +1 -1
- data/spec/{block_5_rect_api_spec.rb → rect_api_spec.rb} +1 -1
- data/spec/{expansion_b_registry_spec.rb → registry_spec.rb} +1 -1
- data/spec/srid_spec.rb +43 -0
- data/spec/{expansion_j_full_tg_api_coverage_spec.rb → tg_api_coverage_spec.rb} +1 -1
- data/spec/to_ewkb_spec.rb +37 -0
- data/spec/{block_2_vendor_spec.rb → vendor_sources_spec.rb} +4 -4
- metadata +87 -38
- data/docs/ACTIVE_RECORD.md +0 -26
- data/docs/AUTO_STRATEGY.md +0 -15
- data/docs/EXPANSION_E_TO_H_STATUS.md +0 -51
- data/docs/FORMAT_COVERAGE.md +0 -23
- data/docs/FULL_TG_API_COVERAGE.md +0 -109
- data/docs/LOW_LEVEL_GEOMETRY.md +0 -121
- data/docs/RACTOR.md +0 -40
- data/docs/REGISTRY.md +0 -37
- data/docs/RELEASE_CHECKLIST.md +0 -39
- /data/spec/{expansion_c_active_record_source_spec.rb → active_record_source_spec.rb} +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "spec_helper"
|
|
4
4
|
|
|
5
|
-
RSpec.describe "
|
|
5
|
+
RSpec.describe "Packed batch API" do
|
|
6
6
|
let(:zone_a) { TG::Geometry.parse_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))") }
|
|
7
7
|
let(:zone_b) { TG::Geometry.parse_wkt("POLYGON ((20 20, 30 20, 30 30, 20 30, 20 20))") }
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "spec_helper"
|
|
4
4
|
|
|
5
|
-
RSpec.describe "
|
|
5
|
+
RSpec.describe "Concurrency hardening" do
|
|
6
6
|
let(:zone_a) { '{"type":"Polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]}' }
|
|
7
7
|
let(:zone_b) { '{"type":"Polygon","coordinates":[[[5,5],[15,5],[15,15],[5,15],[5,5]]]}' }
|
|
8
8
|
let(:zone_c) { '{"type":"Polygon","coordinates":[[[100,100],[110,100],[110,110],[100,110],[100,100]]]}' }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Constructors" do
|
|
6
|
+
let(:square) { [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]] }
|
|
7
|
+
let(:hole_a) { [[2.0, 2.0], [4.0, 2.0], [4.0, 4.0], [2.0, 4.0], [2.0, 2.0]] }
|
|
8
|
+
let(:hole_b) { [[6.0, 6.0], [8.0, 6.0], [8.0, 8.0], [6.0, 8.0], [6.0, 6.0]] }
|
|
9
|
+
|
|
10
|
+
describe ".line_string" do
|
|
11
|
+
it "builds a frozen two-point linestring" do
|
|
12
|
+
geom = TG::Geometry.line_string([[0.0, 0.0], [1.0, 1.0]])
|
|
13
|
+
|
|
14
|
+
expect(geom).to be_frozen
|
|
15
|
+
expect(geom.type).to eq(:linestring)
|
|
16
|
+
expect(geom.line.num_points).to eq(2)
|
|
17
|
+
expect(geom.srid).to be_nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "builds a 100-point linestring" do
|
|
21
|
+
points = 100.times.map { |i| [i.to_f, (i * 2).to_f] }
|
|
22
|
+
geom = TG::Geometry.line_string(points)
|
|
23
|
+
|
|
24
|
+
expect(geom.line.num_points).to eq(100)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "supports explicit index values and srid metadata" do
|
|
28
|
+
%i[default natural ystripes none].each do |index|
|
|
29
|
+
geom = TG::Geometry.line_string([[0, 0], [1, 1]], index: index, srid: 4326)
|
|
30
|
+
expect(geom.srid).to eq(4326)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "rejects too few points" do
|
|
35
|
+
expect { TG::Geometry.line_string([[0, 0]]) }
|
|
36
|
+
.to raise_error(TG::Geometry::ArgumentError, "line_string requires at least 2 points, got 1")
|
|
37
|
+
expect { TG::Geometry.line_string([]) }
|
|
38
|
+
.to raise_error(TG::Geometry::ArgumentError, "line_string requires at least 2 points, got 0")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "rejects non-finite coordinates with the bad point index in the message" do
|
|
42
|
+
expect { TG::Geometry.line_string([[0, 0], [Float::NAN, 1]]) }
|
|
43
|
+
.to raise_error(TG::Geometry::ArgumentError, /point 1/)
|
|
44
|
+
expect { TG::Geometry.line_string([[0, 0], [1, Float::INFINITY]]) }
|
|
45
|
+
.to raise_error(TG::Geometry::ArgumentError, /point 1/)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "rejects out-of-range srid" do
|
|
49
|
+
expect { TG::Geometry.line_string([[0, 0], [1, 1]], srid: -1) }
|
|
50
|
+
.to raise_error(TG::Geometry::ArgumentError)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe ".polygon" do
|
|
55
|
+
it "builds a square polygon" do
|
|
56
|
+
geom = TG::Geometry.polygon(square)
|
|
57
|
+
|
|
58
|
+
expect(geom.type).to eq(:polygon)
|
|
59
|
+
expect(geom.polygon.num_holes).to eq(0)
|
|
60
|
+
expect(geom.covers_xy?(5, 5)).to be(true)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "builds polygons with one or two holes" do
|
|
64
|
+
one_hole = TG::Geometry.polygon(square, holes: [hole_a], srid: 4326)
|
|
65
|
+
two_holes = TG::Geometry.polygon(square, holes: [hole_a, hole_b])
|
|
66
|
+
|
|
67
|
+
expect(one_hole.srid).to eq(4326)
|
|
68
|
+
expect(one_hole.polygon.num_holes).to eq(1)
|
|
69
|
+
expect(one_hole.covers_xy?(3, 3)).to be(false)
|
|
70
|
+
expect(two_holes.polygon.num_holes).to eq(2)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "treats holes: [] like omitted holes" do
|
|
74
|
+
expect(TG::Geometry.polygon(square, holes: []).polygon.num_holes).to eq(0)
|
|
75
|
+
expect(TG::Geometry.polygon(square).polygon.num_holes).to eq(0)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "rejects invalid rings without autoclosing" do
|
|
79
|
+
expect { TG::Geometry.polygon(square[0...-1]) }
|
|
80
|
+
.to raise_error(TG::Geometry::ArgumentError, "polygon exterior ring is not closed")
|
|
81
|
+
expect { TG::Geometry.polygon(square[0, 3]) }
|
|
82
|
+
.to raise_error(TG::Geometry::ArgumentError, "polygon exterior ring requires at least 4 points")
|
|
83
|
+
expect { TG::Geometry.polygon(square, holes: [hole_a[0...-1]]) }
|
|
84
|
+
.to raise_error(TG::Geometry::ArgumentError, "polygon hole 0 is not closed")
|
|
85
|
+
expect { TG::Geometry.polygon(square, holes: [hole_a[0, 3]]) }
|
|
86
|
+
.to raise_error(TG::Geometry::ArgumentError, "polygon hole 0 requires at least 4 points")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe ".multi_polygon" do
|
|
91
|
+
it "builds empty and non-empty multipolygons" do
|
|
92
|
+
expect(TG::Geometry.multi_polygon([]).type).to eq(:multipolygon)
|
|
93
|
+
|
|
94
|
+
geom = TG::Geometry.multi_polygon([
|
|
95
|
+
{ exterior: square, holes: [hole_a] },
|
|
96
|
+
[[20.0, 20.0], [21.0, 20.0], [21.0, 21.0], [20.0, 21.0], [20.0, 20.0]]
|
|
97
|
+
], srid: 3857)
|
|
98
|
+
|
|
99
|
+
expect(geom.type).to eq(:multipolygon)
|
|
100
|
+
expect(geom.srid).to eq(3857)
|
|
101
|
+
expect(geom.num_polygons).to eq(2)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "spec_helper"
|
|
4
4
|
|
|
5
|
-
RSpec.describe "
|
|
5
|
+
RSpec.describe "Error hardening" do
|
|
6
6
|
let(:geojson) { '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1],[0,0]]]}' }
|
|
7
7
|
let(:wkb) { TG::Geometry.parse_geojson(geojson).to_wkb }
|
|
8
8
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
RSpec.describe "FeatureSource no-GVL behavior" do
|
|
7
|
+
def polygon_feature_json(id, x, y)
|
|
8
|
+
<<~JSON.chomp
|
|
9
|
+
{"type":"Feature","properties":{"@id":"zone/#{id}"},"geometry":{"type":"Polygon","coordinates":[[[#{x},#{y}],[#{x + 0.8},#{y}],[#{x + 0.8},#{y + 0.8}],[#{x},#{y + 0.8}],[#{x},#{y}]]]}}
|
|
10
|
+
JSON
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def write_large_feature_collection(count)
|
|
14
|
+
file = Tempfile.new(["tg_geometry_feature_source_nogvl", ".geojson"])
|
|
15
|
+
file.binmode
|
|
16
|
+
file.write('{"type":"FeatureCollection","features":[')
|
|
17
|
+
count.times do |i|
|
|
18
|
+
file.write(",") unless i.zero?
|
|
19
|
+
file.write(polygon_feature_json(i, i % 1000, i / 1000))
|
|
20
|
+
end
|
|
21
|
+
file.write("]}")
|
|
22
|
+
file.flush
|
|
23
|
+
file.close
|
|
24
|
+
file
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "lets other Ruby threads run during large FeatureSource file processing" do
|
|
28
|
+
count = 40_000
|
|
29
|
+
file = write_large_feature_collection(count)
|
|
30
|
+
ticks = 0
|
|
31
|
+
done = false
|
|
32
|
+
|
|
33
|
+
ticker = Thread.new do
|
|
34
|
+
until done
|
|
35
|
+
ticks += 1
|
|
36
|
+
sleep 0.005
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
index = TG::Geometry::FeatureSource.build_index_file(file.path, strategy: :flat)
|
|
41
|
+
done = true
|
|
42
|
+
ticker.join
|
|
43
|
+
|
|
44
|
+
expect(index.size).to eq(count)
|
|
45
|
+
expect(ticks).to be >= 1
|
|
46
|
+
ensure
|
|
47
|
+
done = true
|
|
48
|
+
ticker&.join
|
|
49
|
+
file&.unlink
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "stringio"
|
|
5
|
+
|
|
6
|
+
RSpec.describe "FeatureSource" do
|
|
7
|
+
let(:fixture_dir) { File.join(__dir__, "fixtures", "feature_source") }
|
|
8
|
+
let(:simple_path) { File.join(fixture_dir, "simple_feature_collection.geojson") }
|
|
9
|
+
let(:simple_json) { File.binread(simple_path) }
|
|
10
|
+
|
|
11
|
+
it "defines TG::Geometry::FeatureSource" do
|
|
12
|
+
expect(TG::Geometry.const_defined?(:FeatureSource)).to be(true)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it "reads entries from JSON, file, and IO without parsing properties to Hash" do
|
|
16
|
+
expected = [["zone/a", /"Polygon"/], [2, /"MultiPolygon"/]]
|
|
17
|
+
|
|
18
|
+
[
|
|
19
|
+
TG::Geometry::FeatureSource.read_entries_json(simple_json),
|
|
20
|
+
TG::Geometry::FeatureSource.read_entries_file(simple_path),
|
|
21
|
+
TG::Geometry::FeatureSource.read_entries_io(StringIO.new(simple_json))
|
|
22
|
+
].each do |entries|
|
|
23
|
+
expect(entries.size).to eq(2)
|
|
24
|
+
expected.each_with_index do |(id, geometry_matcher), i|
|
|
25
|
+
expect(entries[i][0]).to eq(id)
|
|
26
|
+
expect(entries[i][1]).to match(geometry_matcher)
|
|
27
|
+
expect(entries[i][1]).to be_a(String)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "reads features with raw properties JSON" do
|
|
33
|
+
features = TG::Geometry::FeatureSource.read_features_json(simple_json)
|
|
34
|
+
|
|
35
|
+
expect(features.size).to eq(2)
|
|
36
|
+
expect(features[0][0]).to eq("zone/a")
|
|
37
|
+
expect(features[0][1]).to include('"Polygon"')
|
|
38
|
+
expect(features[0][2]).to include('"@id": "zone/a"')
|
|
39
|
+
expect(features[1][2]).to include('"@id": 2')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it "uses the OSM-style default id path [properties, @id]" do
|
|
43
|
+
json = File.binread(File.join(fixture_dir, "osm_like_feature_collection.geojson"))
|
|
44
|
+
|
|
45
|
+
entries = TG::Geometry::FeatureSource.read_entries_json(json)
|
|
46
|
+
|
|
47
|
+
expect(entries.first.first).to eq("relation/100")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "filters non polygon types by default and reports filtered separately" do
|
|
51
|
+
json = File.binread(File.join(fixture_dir, "mixed_geometry_types.geojson"))
|
|
52
|
+
|
|
53
|
+
report = TG::Geometry::FeatureSource.read_entries_json(json, report: true)
|
|
54
|
+
|
|
55
|
+
expect(report[:entries].map(&:first)).to eq(["poly"])
|
|
56
|
+
expect(report[:filtered]).to eq(2)
|
|
57
|
+
expect(report[:skipped]).to eq(0)
|
|
58
|
+
expect(report[:errors]).to eq([])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "supports only: nil for no geometry type filtering" do
|
|
62
|
+
json = File.binread(File.join(fixture_dir, "mixed_geometry_types.geojson"))
|
|
63
|
+
|
|
64
|
+
entries = TG::Geometry::FeatureSource.read_entries_json(json, only: nil)
|
|
65
|
+
|
|
66
|
+
expect(entries.map(&:first)).to eq(%w[poly point line])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "requires report mode for skip options" do
|
|
70
|
+
expect do
|
|
71
|
+
TG::Geometry::FeatureSource.read_entries_json(simple_json, on_invalid: :skip)
|
|
72
|
+
end.to raise_error(TG::Geometry::ArgumentError)
|
|
73
|
+
|
|
74
|
+
expect do
|
|
75
|
+
TG::Geometry::FeatureSource.read_entries_json(simple_json, on_missing_id: :skip)
|
|
76
|
+
end.to raise_error(TG::Geometry::ArgumentError)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "supports on_missing_id: :ordinal" do
|
|
80
|
+
json = File.binread(File.join(fixture_dir, "properties_null_missing.geojson"))
|
|
81
|
+
|
|
82
|
+
entries = TG::Geometry::FeatureSource.read_entries_json(json, on_missing_id: :ordinal)
|
|
83
|
+
|
|
84
|
+
expect(entries.map(&:first)).to eq(["feature/0", "feature/1"])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "supports on_missing_id: :skip in report mode without requiring on_invalid: :skip" do
|
|
88
|
+
json = File.binread(File.join(fixture_dir, "properties_null_missing.geojson"))
|
|
89
|
+
|
|
90
|
+
report = TG::Geometry::FeatureSource.read_entries_json(json,
|
|
91
|
+
report: true,
|
|
92
|
+
on_missing_id: :skip)
|
|
93
|
+
|
|
94
|
+
expect(report[:entries]).to eq([])
|
|
95
|
+
expect(report[:skipped]).to eq(2)
|
|
96
|
+
expect(report[:filtered]).to eq(0)
|
|
97
|
+
expect(report[:errors].map { |e| e[:feature_index] }).to eq([0, 1])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "reports invalid geometry with capped errors and exact skipped count" do
|
|
101
|
+
json = File.binread(File.join(fixture_dir, "invalid_geometry_middle.geojson"))
|
|
102
|
+
|
|
103
|
+
report = TG::Geometry::FeatureSource.read_entries_json(json,
|
|
104
|
+
report: true,
|
|
105
|
+
on_invalid: :skip,
|
|
106
|
+
max_errors: 1)
|
|
107
|
+
|
|
108
|
+
expect(report[:entries].map(&:first)).to eq(["ok-a", "ok-b"])
|
|
109
|
+
expect(report[:skipped]).to eq(1)
|
|
110
|
+
expect(report[:errors].size).to eq(1)
|
|
111
|
+
expect(report[:errors].first[:feature_index]).to eq(1)
|
|
112
|
+
expect(report[:errors].first[:reason]).to include("invalid geometry")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "raises ParseError for malformed JSON and non FeatureCollection roots" do
|
|
116
|
+
malformed = File.binread(File.join(fixture_dir, "malformed_json.geojson"))
|
|
117
|
+
|
|
118
|
+
expect { TG::Geometry::FeatureSource.read_entries_json(malformed) }
|
|
119
|
+
.to raise_error(TG::Geometry::ParseError)
|
|
120
|
+
expect { TG::Geometry::FeatureSource.read_entries_json('{"type":"Feature"}') }
|
|
121
|
+
.to raise_error(TG::Geometry::ParseError)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "builds an Index directly from JSON, file, and IO" do
|
|
125
|
+
indexes = [
|
|
126
|
+
TG::Geometry::FeatureSource.build_index_json(simple_json, strategy: :flat),
|
|
127
|
+
TG::Geometry::FeatureSource.build_index_file(simple_path, strategy: :flat),
|
|
128
|
+
TG::Geometry::FeatureSource.build_index_io(StringIO.new(simple_json), strategy: :flat)
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
indexes.each do |index|
|
|
132
|
+
expect(index).to be_a(TG::Geometry::Index)
|
|
133
|
+
expect(index).to be_frozen
|
|
134
|
+
expect(index.size).to eq(2)
|
|
135
|
+
expect(index.find_covering(5, 5)).to eq("zone/a")
|
|
136
|
+
expect(index.find_covering(25, 25)).to eq(2)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it "matches Index.build(read_entries, via: :geojson) behavior" do
|
|
141
|
+
entries = TG::Geometry::FeatureSource.read_entries_json(simple_json)
|
|
142
|
+
from_entries = TG::Geometry::Index.build(entries, via: :geojson, strategy: :flat)
|
|
143
|
+
direct = TG::Geometry::FeatureSource.build_index_json(simple_json, strategy: :flat)
|
|
144
|
+
|
|
145
|
+
[[5, 5], [25, 25], [100, 100]].each do |lon, lat|
|
|
146
|
+
expect(direct.find_covering(lon, lat)).to eq(from_entries.find_covering(lon, lat))
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
it "raises ParseError with a string reason for missing geometry" do
|
|
152
|
+
json = '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"@id":"x"}}]}'
|
|
153
|
+
|
|
154
|
+
expect { TG::Geometry::FeatureSource.read_entries_json(json) }
|
|
155
|
+
.to raise_error(TG::Geometry::ParseError, /feature 0.*missing geometry/)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "reports missing geometry with a string reason in skip mode" do
|
|
159
|
+
json = '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"@id":"x"}}]}'
|
|
160
|
+
|
|
161
|
+
report = TG::Geometry::FeatureSource.read_entries_json(
|
|
162
|
+
json,
|
|
163
|
+
report: true,
|
|
164
|
+
on_invalid: :skip
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
expect(report[:entries]).to eq([])
|
|
168
|
+
expect(report[:skipped]).to eq(1)
|
|
169
|
+
expect(report[:filtered]).to eq(0)
|
|
170
|
+
expect(report[:errors].first[:reason]).to be_a(String)
|
|
171
|
+
expect(report[:errors].first[:reason]).to include("missing geometry")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it "skips invalid id type when on_invalid is skip and report is true" do
|
|
175
|
+
json = <<~JSON
|
|
176
|
+
{"type":"FeatureCollection","features":[
|
|
177
|
+
{"type":"Feature","properties":{"@id":false},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}}
|
|
178
|
+
]}
|
|
179
|
+
JSON
|
|
180
|
+
|
|
181
|
+
report = TG::Geometry::FeatureSource.read_entries_json(
|
|
182
|
+
json,
|
|
183
|
+
report: true,
|
|
184
|
+
on_invalid: :skip
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
expect(report[:entries]).to eq([])
|
|
188
|
+
expect(report[:skipped]).to eq(1)
|
|
189
|
+
expect(report[:filtered]).to eq(0)
|
|
190
|
+
expect(report[:errors].first[:reason]).to include("invalid id")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "skips fractional numeric id when on_invalid is skip and report is true" do
|
|
194
|
+
json = <<~JSON
|
|
195
|
+
{"type":"FeatureCollection","features":[
|
|
196
|
+
{"type":"Feature","properties":{"@id":1.2},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}}
|
|
197
|
+
]}
|
|
198
|
+
JSON
|
|
199
|
+
|
|
200
|
+
report = TG::Geometry::FeatureSource.read_entries_json(
|
|
201
|
+
json,
|
|
202
|
+
report: true,
|
|
203
|
+
on_invalid: :skip
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
expect(report[:entries]).to eq([])
|
|
207
|
+
expect(report[:skipped]).to eq(1)
|
|
208
|
+
expect(report[:errors].first[:reason]).to include("numeric id must be an integer")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it "raises ParseError for malformed feature shapes" do
|
|
212
|
+
cases = [
|
|
213
|
+
'[1]',
|
|
214
|
+
'{"type":"Feature","properties":{"@id":"x"},"geometry":null}',
|
|
215
|
+
'{"type":"Feature","properties":{"@id":"x"},"geometry":"bad"}',
|
|
216
|
+
'{"type":"Feature","properties":{"@id":"x"},"geometry":{"coordinates":[]}}',
|
|
217
|
+
'{"type":"Feature","properties":{"@id":"x"},"geometry":{"type":1,"coordinates":[]}}'
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
cases.each do |feature_json|
|
|
221
|
+
json = "{\"type\":\"FeatureCollection\",\"features\":[#{feature_json}]}"
|
|
222
|
+
expect { TG::Geometry::FeatureSource.read_entries_json(json) }
|
|
223
|
+
.to raise_error(TG::Geometry::ParseError)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it "raises ParseError for missing or non-array features" do
|
|
228
|
+
expect { TG::Geometry::FeatureSource.read_entries_json('{"type":"FeatureCollection"}') }
|
|
229
|
+
.to raise_error(TG::Geometry::ParseError, /features/)
|
|
230
|
+
|
|
231
|
+
expect { TG::Geometry::FeatureSource.read_entries_json('{"type":"FeatureCollection","features":{}}') }
|
|
232
|
+
.to raise_error(TG::Geometry::ParseError, /features/)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it "treats invalid properties type as invalid feature for read_features" do
|
|
236
|
+
json = <<~JSON
|
|
237
|
+
{"type":"FeatureCollection","features":[
|
|
238
|
+
{"type":"Feature","properties":[],"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}}
|
|
239
|
+
]}
|
|
240
|
+
JSON
|
|
241
|
+
|
|
242
|
+
expect { TG::Geometry::FeatureSource.read_features_json(json, on_missing_id: :ordinal) }
|
|
243
|
+
.to raise_error(TG::Geometry::ParseError, /properties must be an object or null/)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it "rejects unknown keywords" do
|
|
247
|
+
expect { TG::Geometry::FeatureSource.read_entries_json(simple_json, typo: 1) }
|
|
248
|
+
.to raise_error(TG::Geometry::ArgumentError, /unknown keyword/)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it "surfaces missing file errors as file errors" do
|
|
252
|
+
missing = File.join(fixture_dir, "does_not_exist.geojson")
|
|
253
|
+
|
|
254
|
+
expect { TG::Geometry::FeatureSource.read_entries_file(missing) }
|
|
255
|
+
.to raise_error(Errno::ENOENT)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "rejects invalid id types" do
|
|
259
|
+
json = <<~JSON
|
|
260
|
+
{"type":"FeatureCollection","features":[
|
|
261
|
+
{"type":"Feature","properties":{"@id":false},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}}
|
|
262
|
+
]}
|
|
263
|
+
JSON
|
|
264
|
+
|
|
265
|
+
expect { TG::Geometry::FeatureSource.read_entries_json(json) }
|
|
266
|
+
.to raise_error(TG::Geometry::ArgumentError)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{"type":"Feature","properties":{"@id":"ok-a"},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]] }},
|
|
5
|
+
{"type":"Feature","properties":{"@id":"bad"},"geometry":{"type":"Polygon","coordinates":"not coordinates"}},
|
|
6
|
+
{"type":"Feature","properties":{"@id":"ok-b"},"geometry":{"type":"Polygon","coordinates":[[[2,2],[2,3],[3,3],[3,2],[2,2]]] }}
|
|
7
|
+
]
|
|
8
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"FeatureCollection","features":[
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{"type":"Feature","properties":{"@id":"poly"},"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}},
|
|
5
|
+
{"type":"Feature","properties":{"@id":"point"},"geometry":{"type":"Point","coordinates":[5,5]}},
|
|
6
|
+
{"type":"Feature","properties":{"@id":"line"},"geometry":{"type":"LineString","coordinates":[[0,0],[1,1]]}}
|
|
7
|
+
]
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{"type":"Feature","properties":null,"geometry":{"type":"Polygon","coordinates":[[[0,0],[0,1],[1,1],[1,0],[0,0]]]}},
|
|
5
|
+
{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[2,2],[2,3],[3,3],[3,2],[2,2]]]}}
|
|
6
|
+
]
|
|
7
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{
|
|
5
|
+
"type": "Feature",
|
|
6
|
+
"properties": {"@id": "zone/a", "name": "A"},
|
|
7
|
+
"geometry": {"type": "Polygon", "coordinates": [[[0,0],[0,10],[10,10],[10,0],[0,0]]]}
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"type": "Feature",
|
|
11
|
+
"properties": {"@id": 2},
|
|
12
|
+
"geometry": {"type": "MultiPolygon", "coordinates": [[[[20,20],[20,30],[30,30],[30,20],[20,20]]]]}
|
|
13
|
+
}
|
|
14
|
+
]
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# PostGIS / EWKB fixtures
|
|
2
|
+
|
|
3
|
+
These files are committed binary EWKB fixtures used by the v0.3.0 SRID/EWKB tests.
|
|
4
|
+
They are intentionally small and are not generated at spec runtime.
|
|
5
|
+
|
|
6
|
+
Canonical PostGIS generation examples:
|
|
7
|
+
|
|
8
|
+
```sql
|
|
9
|
+
SELECT ST_AsEWKB(ST_GeomFromText('POINT(37.6 55.7)', 4326));
|
|
10
|
+
SELECT ST_AsEWKB(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))', 4326));
|
|
11
|
+
SELECT ST_AsEWKB(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0),(2 2,4 2,4 4,2 4,2 2))', 4326));
|
|
12
|
+
SELECT ST_AsEWKB(ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))', 3857));
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`multipolygon_large.ewkb` is a synthetic large multipolygon fixture with about 7000 points.
|
|
16
|
+
It stands in for a real imported polygon while keeping the repository small.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "FeatureCollection",
|
|
3
|
+
"features": [
|
|
4
|
+
{
|
|
5
|
+
"type": "Feature",
|
|
6
|
+
"properties": {
|
|
7
|
+
"name": "inside_simple"
|
|
8
|
+
},
|
|
9
|
+
"geometry": {
|
|
10
|
+
"type": "Point",
|
|
11
|
+
"coordinates": [
|
|
12
|
+
1.0,
|
|
13
|
+
1.0
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"type": "Feature",
|
|
19
|
+
"properties": {
|
|
20
|
+
"name": "boundary_simple"
|
|
21
|
+
},
|
|
22
|
+
"geometry": {
|
|
23
|
+
"type": "Point",
|
|
24
|
+
"coordinates": [
|
|
25
|
+
0.0,
|
|
26
|
+
5.0
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"type": "Feature",
|
|
32
|
+
"properties": {
|
|
33
|
+
"name": "hole_center"
|
|
34
|
+
},
|
|
35
|
+
"geometry": {
|
|
36
|
+
"type": "Point",
|
|
37
|
+
"coordinates": [
|
|
38
|
+
3.0,
|
|
39
|
+
3.0
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"type": "Feature",
|
|
45
|
+
"properties": {
|
|
46
|
+
"name": "between_hole_and_outer"
|
|
47
|
+
},
|
|
48
|
+
"geometry": {
|
|
49
|
+
"type": "Point",
|
|
50
|
+
"coordinates": [
|
|
51
|
+
1.0,
|
|
52
|
+
1.0
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "Feature",
|
|
58
|
+
"properties": {
|
|
59
|
+
"name": "large_inside"
|
|
60
|
+
},
|
|
61
|
+
"geometry": {
|
|
62
|
+
"type": "Point",
|
|
63
|
+
"coordinates": [
|
|
64
|
+
100.0,
|
|
65
|
+
50.0
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"type": "Feature",
|
|
71
|
+
"properties": {
|
|
72
|
+
"name": "large_outside"
|
|
73
|
+
},
|
|
74
|
+
"geometry": {
|
|
75
|
+
"type": "Point",
|
|
76
|
+
"coordinates": [
|
|
77
|
+
200.0,
|
|
78
|
+
50.0
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "spec_helper"
|
|
4
4
|
require "fileutils"
|
|
5
5
|
|
|
6
|
-
RSpec.describe "
|
|
6
|
+
RSpec.describe "Fuzz hardening" do
|
|
7
7
|
ITERATIONS = Integer(ENV.fetch("TG_GEOMETRY_FUZZ_ITERATIONS", "2000")).freeze
|
|
8
8
|
SEED = Integer(ENV.fetch("TG_GEOMETRY_FUZZ_SEED", "20260524")).freeze
|
|
9
9
|
CORPUS_DIR = File.expand_path("fixtures/fuzz_corpus", __dir__).freeze
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "spec_helper"
|
|
4
4
|
require "objspace"
|
|
5
5
|
|
|
6
|
-
RSpec.describe "
|
|
6
|
+
RSpec.describe "Geom parsing" do
|
|
7
7
|
let(:geojson_point) { %({"type":"Point","coordinates":[1.0,2.0]}) }
|
|
8
8
|
let(:wkt_point) { "POINT (1 2)" }
|
|
9
9
|
let(:wkb_point) { [1, 1].pack("CL<") + [1.0, 2.0].pack("E2") }
|