Repository: planetlabs/gpq
Pull request #12 (2023-05-05 16:23:22)

Incrementally build converters and schema
Previously, one feature at a time would be considered when building a Parquet schema from GeoJSON.  This meant that feature collections with "sparse" properties (lots of nulls or missing properties in some features) could not reliably be converted to GeoParquet.

With this change, the `convert` command takes `--min` and `--max` arguments to control the minimum and maximum number of GeoJSON features to consider when building a schema.  The schema is incrementally built using at least `min` features, and efforts to build a complete schema continue until `max` features.


Patch:
diff --git a/cmd/gpq/convert.go b/cmd/gpq/convert.go
index b0ed27b..7bb9d07 100644
--- a/cmd/gpq/convert.go
+++ b/cmd/gpq/convert.go
@@ -28,6 +28,8 @@ type ConvertCmd struct {
 	From   string `help:"Input file format.  Possible values: ${enum}." enum:"auto, geojson, geoparquet" default:"auto"`
 	Output string `arg:"" name:"output" help:"Output file." type:"path"`
 	To     string `help:"Output file format.  Possible values: ${enum}." enum:"auto, geojson, geoparquet" default:"auto"`
+	Min    int    `help:"Minimum number of features to consider when building a schema." default:"10"`
+	Max    int    `help:"Maximum number of features to consider when building a schema." default:"100"`
 }
 
 type FormatType string
@@ -57,7 +59,7 @@ func getFormatType(filename string) FormatType {
 	if strings.HasSuffix(filename, ".json") || strings.HasSuffix(filename, ".geojson") {
 		return GeoJSONType
 	}
-	if strings.HasSuffix(filename, ".parquet") || strings.HasSuffix(filename, ".geoparquet") {
+	if strings.HasSuffix(filename, ".pq") || strings.HasSuffix(filename, ".parquet") || strings.HasSuffix(filename, ".geoparquet") {
 		return GeoParquetType
 	}
 	return UnknownType
@@ -110,5 +112,6 @@ func (c *ConvertCmd) Run() error {
 		return geojson.FromParquet(file, output)
 	}
 
-	return geojson.ToParquet(input, output)
+	convertOptions := &geojson.ConvertOptions{MinFeatures: c.Min, MaxFeatures: c.Max}
+	return geojson.ToParquet(input, output, convertOptions)
 }
diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go
index b7766d5..61db93b 100644
--- a/cmd/wasm/main.go
+++ b/cmd/wasm/main.go
@@ -87,7 +87,7 @@ var toParquet = js.FuncOf(func(this js.Value, args []js.Value) any {
 
 	input := strings.NewReader(args[0].String())
 	output := &bytes.Buffer{}
-	convertErr := geojson.ToParquet(input, output)
+	convertErr := geojson.ToParquet(input, output, &geojson.ConvertOptions{MinFeatures: 10, MaxFeatures: 250})
 
 	if convertErr != nil {
 		return returnFromError(convertErr)
diff --git a/internal/geojson/geojson.go b/internal/geojson/geojson.go
index b5f5c59..21866c9 100644
--- a/internal/geojson/geojson.go
+++ b/internal/geojson/geojson.go
@@ -230,25 +230,26 @@ func NewFeatureReader(input io.Reader) *FeatureReader {
 	}
 }
 
-func (reader *FeatureReader) Converter(max int) (*TypeConverter, error) {
+func (reader *FeatureReader) Converter(min int, max int) (*TypeConverter, error) {
 	features := []*Feature{}
+	schemaBuilder := &SchemaBuilder{}
 	for attempts := 0; attempts < max-1; attempts += 1 {
 		feature, readErr := reader.Next()
 		if readErr == io.EOF {
 			if attempts == 0 {
 				return nil, errors.New("empty feature collection")
 			}
-			return nil, fmt.Errorf("failed to generate schema from first %d features", attempts)
+			reader.buffer = features
+			return schemaBuilder.Converter()
 		}
 		if readErr != nil {
 			return nil, readErr
 		}
 		features = append(features, feature)
 
-		converter, converterErr := ConverterFromFeature(feature)
-		if converterErr == nil {
+		if complete := schemaBuilder.Add(feature); complete && attempts >= min-1 {
 			reader.buffer = features
-			return converter, nil
+			return schemaBuilder.Converter()
 		}
 	}
 	return nil, fmt.Errorf("failed to generate converter from first %d features", max)
@@ -511,11 +512,23 @@ func (r *FeatureReader) readGeometryCollection() (*Feature, error) {
 	return feature, nil
 }
 
-func ToParquet(input io.Reader, output io.Writer) error {
+type ConvertOptions struct {
+	MinFeatures int
+	MaxFeatures int
+}
+
+var defaultOptions = &ConvertOptions{
+	MinFeatures: 1,
+	MaxFeatures: 50,
+}
+
+func ToParquet(input io.Reader, output io.Writer, convertOptions *ConvertOptions) error {
 	reader := NewFeatureReader(input)
 
-	max := 50
-	converter, converterErr := reader.Converter(max)
+	if convertOptions == nil {
+		convertOptions = defaultOptions
+	}
+	converter, converterErr := reader.Converter(convertOptions.MinFeatures, convertOptions.MaxFeatures)
 	if converterErr != nil {
 		return converterErr
 	}
diff --git a/internal/geojson/schema.go b/internal/geojson/schema.go
index 3db5ec6..5e70d93 100644
--- a/internal/geojson/schema.go
+++ b/internal/geojson/schema.go
@@ -37,7 +37,7 @@ type TypeConverter struct {
 	Convert ConvertFn
 }
 
-func converterFromAny(v any) (*TypeConverter, error) {
+func typeConverterFromAny(v any) (*TypeConverter, error) {
 	if v == nil {
 		return nil, errors.New("cannot determine type from null")
 	}
@@ -50,20 +50,20 @@ func converterFromAny(v any) (*TypeConverter, error) {
 		}
 		return converter, nil
 	case map[string]any:
-		return converterFromMap(value)
+		return typeConverterFromMap(value)
 	case []any:
-		return converterFromSlice(value)
+		return typeConverterFromSlice(value)
 	default:
 		return nil, fmt.Errorf("unsupported type: %t", value)
 	}
 }
 
-func converterFromSlice(data []any) (*TypeConverter, error) {
+func typeConverterFromSlice(data []any) (*TypeConverter, error) {
 	if len(data) == 0 {
 		return nil, errors.New("cannot determine type from empty array")
 	}
 
-	itemConverter, err := converterFromAny(data[0])
+	itemConverter, err := typeConverterFromAny(data[0])
 	if err != nil {
 		return nil, err
 	}
@@ -94,7 +94,7 @@ func converterFromSlice(data []any) (*TypeConverter, error) {
 				if itemValue.Type() != itemType {
 					return nil, fmt.Errorf("mixed array, expected %s, but got %s", itemType, itemValue.Type())
 				}
-				slice.Index(i).Set(reflect.ValueOf(value))
+				slice.Index(i).Set(itemValue)
 			}
 			return slice.Interface(), nil
 		},
@@ -108,7 +108,7 @@ type FieldConverter struct {
 	Convert ConvertFn
 }
 
-func converterFromMap(data map[string]any) (*TypeConverter, error) {
+func typeConverterFromMap(data map[string]any) (*TypeConverter, error) {
 	fieldConverters, err := fieldConvertersFromMap(data)
 	if err != nil {
 		return nil, err
@@ -179,28 +179,38 @@ func fieldName(key string, offset int) string {
 	return fmt.Sprintf("%s_%d", string(letters), offset)
 }
 
+func fieldConverterFromAny(key string, offset int, value any) (*FieldConverter, error) {
+	typeConverter, err := typeConverterFromAny(value)
+	if err != nil {
+		return nil, err
+	}
+
+	repetition := "optional"
+	if typeConverter.Type.Kind() == reflect.Slice {
+		repetition = ""
+	}
+
+	fieldConverter := &FieldConverter{
+		Field: reflect.StructField{
+			Name: fieldName(key, offset),
+			Type: typeConverter.Type,
+			Tag:  makeStructTag("parquet", key, repetition),
+		},
+		Convert: typeConverter.Convert,
+	}
+
+	return fieldConverter, nil
+}
+
 func fieldConvertersFromMap(data map[string]any) (map[string]*FieldConverter, error) {
 	fieldConverters := map[string]*FieldConverter{}
 	for key, v := range data {
-		converter, err := converterFromAny(v)
+		fieldConverter, err := fieldConverterFromAny(key, len(fieldConverters), v)
 		if err != nil {
 			return nil, err
 		}
 
-		repetition := "optional"
-		if converter.Type.Kind() == reflect.Slice {
-			repetition = ""
-		}
-
-		field := reflect.StructField{
-			Name: fieldName(key, len(fieldConverters)),
-			Type: converter.Type,
-			Tag:  makeStructTag("parquet", key, repetition),
-		}
-		fieldConverters[key] = &FieldConverter{
-			Field:   field,
-			Convert: converter.Convert,
-		}
+		fieldConverters[key] = fieldConverter
 	}
 	return fieldConverters, nil
 }
@@ -216,24 +226,75 @@ func makeStructTag(name string, values ...string) reflect.StructTag {
 	return reflect.StructTag(fmt.Sprintf("%s:%q", name, strings.Join(nonEmptyValues, ",")))
 }
 
-func ConverterFromFeature(feature *Feature) (*TypeConverter, error) {
-	fieldConverters, fieldErr := fieldConvertersFromMap(feature.Properties)
-	if fieldErr != nil {
-		return nil, fieldErr
+type SchemaBuilder struct {
+	fieldConverters map[string]*FieldConverter
+	lastError       error
+}
+
+func (sb *SchemaBuilder) Error() error {
+	return sb.lastError
+}
+
+func (sb *SchemaBuilder) isComplete() bool {
+	if sb.fieldConverters == nil {
+		return false
+	}
+	for _, v := range sb.fieldConverters {
+		if v == nil {
+			return false
+		}
+	}
+	return true
+}
+
+func (sb *SchemaBuilder) Add(feature *Feature) bool {
+	if sb.fieldConverters == nil {
+		sb.fieldConverters = map[string]*FieldConverter{}
+	}
+
+	fieldConverters := sb.fieldConverters
+	for key, value := range feature.Properties {
+		if value == nil {
+			if _, ok := fieldConverters[key]; !ok {
+				if sb.lastError == nil {
+					sb.lastError = fmt.Errorf("null value for %q", key)
+				}
+				fieldConverters[key] = nil
+			}
+			continue
+		}
+
+		if fieldConverters[key] != nil {
+			continue
+		}
+
+		fieldConverter, err := fieldConverterFromAny(key, len(fieldConverters), value)
+		if err != nil {
+			sb.lastError = err
+			fieldConverters[key] = nil
+			continue
+		}
+
+		fieldConverters[key] = fieldConverter
+	}
+
+	if fieldConverters[primaryColumn] != nil {
+		return sb.isComplete()
 	}
 
 	geometryData, wkbErr := wkb.Marshal(feature.Geometry)
 	if wkbErr != nil {
-		return nil, fmt.Errorf("failed to encode geometry: %w", wkbErr)
+		fieldConverters[primaryColumn] = nil
+		sb.lastError = fmt.Errorf("failed to encode geometry: %w", wkbErr)
+		return false
 	}
 
-	geometryField := reflect.StructField{
-		Name: fieldName(primaryColumn, len(fieldConverters)),
-		Type: reflect.TypeOf(geometryData),
-		Tag:  makeStructTag("parquet", primaryColumn, "optional"),
-	}
 	fieldConverters[primaryColumn] = &FieldConverter{
-		Field: geometryField,
+		Field: reflect.StructField{
+			Name: fieldName(primaryColumn, len(fieldConverters)),
+			Type: reflect.TypeOf(geometryData),
+			Tag:  makeStructTag("parquet", primaryColumn, "optional"),
+		},
 		Convert: func(v any) (any, error) {
 			geometry, ok := v.(orb.Geometry)
 			if !ok {
@@ -243,7 +304,19 @@ func ConverterFromFeature(feature *Feature) (*TypeConverter, error) {
 		},
 	}
 
-	converter, converterErr := structConverter(fieldConverters)
+	return sb.isComplete()
+}
+
+func (sb *SchemaBuilder) Converter() (*TypeConverter, error) {
+	if !sb.isComplete() {
+		if err := sb.Error(); err != nil {
+			return nil, err
+		}
+
+		return nil, errors.New("not enough features have been added to build a schema")
+	}
+
+	converter, converterErr := structConverter(sb.fieldConverters)
 	if converterErr != nil {
 		return nil, converterErr
 	}
@@ -266,8 +339,8 @@ func ConverterFromFeature(feature *Feature) (*TypeConverter, error) {
 	return featureConverter, nil
 }
 
-func SchemaOf(feature *Feature) (*parquet.Schema, error) {
-	converter, err := ConverterFromFeature(feature)
+func (sb *SchemaBuilder) Schema() (*parquet.Schema, error) {
+	converter, err := sb.Converter()
 	if err != nil {
 		return nil, err
 	}
@@ -275,3 +348,9 @@ func SchemaOf(feature *Feature) (*parquet.Schema, error) {
 	schema := parquet.SchemaOf(reflect.New(converter.Type).Elem().Interface())
 	return schema, nil
 }
+
+func SchemaOf(feature *Feature) (*parquet.Schema, error) {
+	schemaBuilder := &SchemaBuilder{}
+	schemaBuilder.Add(feature)
+	return schemaBuilder.Schema()
+}
diff --git a/readme.md b/readme.md
index 7cfced8..c7c99c2 100644
--- a/readme.md
+++ b/readme.md
@@ -62,6 +62,5 @@ gpq describe example.parquet
  * Non-geographic CRS information is not preserved when converting GeoParquet to GeoJSON.
  * Page and row group size is not configurable when writing GeoParquet.  This may change soon.
  * GeoParquet files are written using ZSTD compression.  This is not configurable but may change soon.
- * When reading GeoJSON, the schema is inferred from the first feature that has all non-null properties.  All other features must conform with this schema.
  * Reading GeoParquet files with multiple geometry columns is supported.  Reading GeoJSON files with multiple geometry properties is not supported.
  * Feature identifiers in GeoJSON are not written to GeoParquet columns.  This may change soon.


Test patch:
diff --git a/internal/geojson/geojson_test.go b/internal/geojson/geojson_test.go
index ce69b93..258b38c 100644
--- a/internal/geojson/geojson_test.go
+++ b/internal/geojson/geojson_test.go
@@ -203,7 +203,7 @@ func TestToParquet(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	assert.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -240,7 +240,7 @@ func TestToParquetMismatchedTypes(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	assert.EqualError(t, toParquetErr, "mixed types for \"stringProperty\", expected string, but got float64")
 }
 
@@ -249,7 +249,7 @@ func TestToParquetRepeatedProps(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -274,7 +274,7 @@ func TestToParquetNullGeometry(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -299,7 +299,7 @@ func TestToParquetAllNullGeometry(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -331,7 +331,7 @@ func TestToParqueStringId(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -350,7 +350,7 @@ func TestToParqueNumberId(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -369,7 +369,7 @@ func TestToParqueBooleanId(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	assert.ErrorContains(t, toParquetErr, "expected id to be a string or number, got: true")
 }
 
@@ -378,7 +378,7 @@ func TestToParqueArrayId(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	assert.ErrorContains(t, toParquetErr, "expected id to be a string or number, got: [")
 }
 
@@ -387,7 +387,7 @@ func TestToParqueObjectId(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	assert.ErrorContains(t, toParquetErr, "expected id to be a string or number, got: {")
 }
 
@@ -396,7 +396,7 @@ func TestToParquetWithCRS(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -415,7 +415,7 @@ func TestToParquetExtraArray(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -446,7 +446,7 @@ func TestToParquetExtraObject(t *testing.T) {
 	require.NoError(t, openErr)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer)
+	toParquetErr := geojson.ToParquet(geojsonFile, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -479,7 +479,7 @@ func TestRoundTripRepeatedProps(t *testing.T) {
 	inputReader := bytes.NewReader(inputData)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(inputReader, parquetBuffer)
+	toParquetErr := geojson.ToParquet(inputReader, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -500,7 +500,7 @@ func TestRoundTripNestedProps(t *testing.T) {
 	inputReader := bytes.NewReader(inputData)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(inputReader, parquetBuffer)
+	toParquetErr := geojson.ToParquet(inputReader, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
@@ -521,7 +521,28 @@ func TestRoundTripNullGeometry(t *testing.T) {
 	inputReader := bytes.NewReader(inputData)
 
 	parquetBuffer := &bytes.Buffer{}
-	toParquetErr := geojson.ToParquet(inputReader, parquetBuffer)
+	toParquetErr := geojson.ToParquet(inputReader, parquetBuffer, nil)
+	require.NoError(t, toParquetErr)
+
+	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
+	parquetFile, openErr := parquet.OpenFile(parquetInput, parquetInput.Size())
+	require.NoError(t, openErr)
+
+	jsonBuffer := &bytes.Buffer{}
+	convertErr := geojson.FromParquet(parquetFile, jsonBuffer)
+	require.NoError(t, convertErr)
+
+	assert.JSONEq(t, string(inputData), jsonBuffer.String())
+}
+
+func TestRoundTripSparseProperties(t *testing.T) {
+	inputPath := "testdata/sparse-properties.geojson"
+	inputData, readErr := os.ReadFile(inputPath)
+	require.NoError(t, readErr)
+	inputReader := bytes.NewReader(inputData)
+
+	parquetBuffer := &bytes.Buffer{}
+	toParquetErr := geojson.ToParquet(inputReader, parquetBuffer, nil)
 	require.NoError(t, toParquetErr)
 
 	parquetInput := bytes.NewReader(parquetBuffer.Bytes())
diff --git a/internal/geojson/schema_test.go b/internal/geojson/schema_test.go
index 0129b5d..0d7b5ab 100644
--- a/internal/geojson/schema_test.go
+++ b/internal/geojson/schema_test.go
@@ -106,6 +106,12 @@ func TestSchemaOfArrayOfNumbers(t *testing.T) {
 	assert.Equal(t, parquet.DoubleType, column.Node.Type())
 }
 
+func converterFromFeature(feature *geojson.Feature) (*geojson.TypeConverter, error) {
+	schemaBuilder := &geojson.SchemaBuilder{}
+	schemaBuilder.Add(feature)
+	return schemaBuilder.Converter()
+}
+
 func TestConverterSliceOfFloat(t *testing.T) {
 	path := "test"
 
@@ -116,7 +122,7 @@ func TestConverterSliceOfFloat(t *testing.T) {
 		},
 	}
 
-	converter, converterErr := geojson.ConverterFromFeature(feature)
+	converter, converterErr := converterFromFeature(feature)
 	require.NoError(t, converterErr)
 
 	_, convertErr := converter.Convert(feature)
@@ -133,7 +139,7 @@ func TestConverterSliceOfString(t *testing.T) {
 		},
 	}
 
-	converter, converterErr := geojson.ConverterFromFeature(feature)
+	converter, converterErr := converterFromFeature(feature)
 	require.NoError(t, converterErr)
 
 	_, convertErr := converter.Convert(feature)
@@ -150,7 +156,7 @@ func TestConverterSliceOfMixed(t *testing.T) {
 		},
 	}
 
-	converter, converterErr := geojson.ConverterFromFeature(feature)
+	converter, converterErr := converterFromFeature(feature)
 	require.NoError(t, converterErr)
 
 	mixed := &geojson.Feature{
@@ -174,7 +180,7 @@ func TestConverterNilGeometry(t *testing.T) {
 		},
 	}
 
-	converter, converterErr := geojson.ConverterFromFeature(feature)
+	converter, converterErr := converterFromFeature(feature)
 	require.NoError(t, converterErr)
 
 	null := &geojson.Feature{
@@ -196,7 +202,7 @@ func TestConverterNilSlice(t *testing.T) {
 		},
 	}
 
-	converter, converterErr := geojson.ConverterFromFeature(feature)
+	converter, converterErr := converterFromFeature(feature)
 	require.NoError(t, converterErr)
 
 	null := &geojson.Feature{
@@ -206,3 +212,139 @@ func TestConverterNilSlice(t *testing.T) {
 	_, convertErr := converter.Convert(null)
 	assert.NoError(t, convertErr)
 }
+
+func TestSchemaBuilder(t *testing.T) {
+	prop1 := "test-property-1"
+	prop2 := "test-property-2"
+
+	features := []*geojson.Feature{
+		{
+			Properties: map[string]any{
+				prop1: "test-value-1",
+				prop2: "test-value-2",
+			},
+		},
+	}
+
+	schemaBuilder := &geojson.SchemaBuilder{}
+	complete := schemaBuilder.Add(features[0])
+	assert.True(t, complete)
+
+	schema, schemaErr := schemaBuilder.Schema()
+	require.NoError(t, schemaErr)
+
+	require.NoError(t, schemaErr)
+	require.Len(t, schema.Fields(), 3)
+
+	col1, ok := schema.Lookup(prop1)
+	require.True(t, ok)
+	assert.True(t, col1.Node.Optional())
+	assert.Equal(t, parquet.String().Type(), col1.Node.Type())
+
+	col2, ok := schema.Lookup(prop2)
+	require.True(t, ok)
+	assert.True(t, col2.Node.Optional())
+	assert.Equal(t, parquet.String().Type(), col2.Node.Type())
+
+	geom, ok := schema.Lookup("geometry")
+	require.True(t, ok)
+	assert.True(t, geom.Node.Optional())
+	assert.Equal(t, parquet.ByteArrayType, geom.Node.Type())
+}
+
+func TestSchemaBuilderSparse(t *testing.T) {
+	prop1 := "test-property-1"
+	prop2 := "test-property-2"
+
+	features := []*geojson.Feature{
+		{
+			Properties: map[string]any{
+				prop1: "test-value-1",
+			},
+		},
+		{
+			Properties: map[string]any{
+				prop2: "test-value-2",
+			},
+		},
+	}
+
+	schemaBuilder := &geojson.SchemaBuilder{}
+
+	assert.True(t, schemaBuilder.Add(features[0]))
+	assert.True(t, schemaBuilder.Add(features[1]))
+
+	schema, schemaErr := schemaBuilder.Schema()
+	require.NoError(t, schemaErr)
+
+	require.NoError(t, schemaErr)
+	require.Len(t, schema.Fields(), 3)
+
+	col1, ok := schema.Lookup(prop1)
+	require.True(t, ok)
+	assert.True(t, col1.Node.Optional())
+	assert.Equal(t, parquet.String().Type(), col1.Node.Type())
+
+	col2, ok := schema.Lookup(prop2)
+	require.True(t, ok)
+	assert.True(t, col2.Node.Optional())
+	assert.Equal(t, parquet.String().Type(), col2.Node.Type())
+
+	geom, ok := schema.Lookup("geometry")
+	require.True(t, ok)
+	assert.True(t, geom.Node.Optional())
+	assert.Equal(t, parquet.ByteArrayType, geom.Node.Type())
+}
+
+func TestSchemaBuilderSparseNulls(t *testing.T) {
+	prop1 := "test-property-1"
+	prop2 := "test-property-2"
+
+	features := []*geojson.Feature{
+		{
+			Properties: map[string]any{
+				prop1: "test-value-1",
+				prop2: nil,
+			},
+		},
+		{
+			Properties: map[string]any{
+				prop1: nil,
+				prop2: nil,
+			},
+		},
+		{
+			Properties: map[string]any{
+				prop1: nil,
+				prop2: "test-value-2",
+			},
+		},
+	}
+
+	schemaBuilder := &geojson.SchemaBuilder{}
+
+	assert.False(t, schemaBuilder.Add(features[0]))
+	assert.False(t, schemaBuilder.Add(features[1]))
+	assert.True(t, schemaBuilder.Add(features[2]))
+
+	schema, schemaErr := schemaBuilder.Schema()
+	require.NoError(t, schemaErr)
+
+	require.NoError(t, schemaErr)
+	require.Len(t, schema.Fields(), 3)
+
+	col1, ok := schema.Lookup(prop1)
+	require.True(t, ok)
+	assert.True(t, col1.Node.Optional())
+	assert.Equal(t, parquet.String().Type(), col1.Node.Type())
+
+	col2, ok := schema.Lookup(prop2)
+	require.True(t, ok)
+	assert.True(t, col2.Node.Optional())
+	assert.Equal(t, parquet.String().Type(), col2.Node.Type())
+
+	geom, ok := schema.Lookup("geometry")
+	require.True(t, ok)
+	assert.True(t, geom.Node.Optional())
+	assert.Equal(t, parquet.ByteArrayType, geom.Node.Type())
+}
diff --git a/internal/geojson/testdata/sparse-properties.geojson b/internal/geojson/testdata/sparse-properties.geojson
new file mode 100644
index 0000000..d5e679d
--- /dev/null
+++ b/internal/geojson/testdata/sparse-properties.geojson
@@ -0,0 +1,32 @@
+{
+  "type": "FeatureCollection",
+  "features": [
+    {
+      "type": "Feature",
+      "geometry": null,
+      "properties": {
+        "first": "one",
+        "second": null,
+        "third": null
+      }
+    },
+    {
+      "type": "Feature",
+      "geometry": null,
+      "properties": {
+        "first": null,
+        "second": "two",
+        "third": null
+      }
+    },
+    {
+      "type": "Feature",
+      "geometry": null,
+      "properties": {
+        "first": null,
+        "second": null,
+        "third": "three"
+      }
+    }
+  ]
+}