parquet 0.5.12 → 0.6.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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/Cargo.lock +295 -98
  3. data/Cargo.toml +1 -1
  4. data/Gemfile +1 -0
  5. data/README.md +94 -3
  6. data/ext/parquet/Cargo.toml +8 -5
  7. data/ext/parquet/src/adapter_ffi.rs +156 -0
  8. data/ext/parquet/src/lib.rs +13 -21
  9. data/ext/parquet-core/Cargo.toml +23 -0
  10. data/ext/parquet-core/src/arrow_conversion.rs +1133 -0
  11. data/ext/parquet-core/src/error.rs +163 -0
  12. data/ext/parquet-core/src/lib.rs +60 -0
  13. data/ext/parquet-core/src/reader.rs +263 -0
  14. data/ext/parquet-core/src/schema.rs +283 -0
  15. data/ext/parquet-core/src/test_utils.rs +308 -0
  16. data/ext/parquet-core/src/traits/mod.rs +5 -0
  17. data/ext/parquet-core/src/traits/schema.rs +151 -0
  18. data/ext/parquet-core/src/value.rs +209 -0
  19. data/ext/parquet-core/src/writer.rs +839 -0
  20. data/ext/parquet-core/tests/arrow_conversion_tests.rs +423 -0
  21. data/ext/parquet-core/tests/binary_data.rs +437 -0
  22. data/ext/parquet-core/tests/column_projection.rs +557 -0
  23. data/ext/parquet-core/tests/complex_types.rs +821 -0
  24. data/ext/parquet-core/tests/compression_tests.rs +434 -0
  25. data/ext/parquet-core/tests/concurrent_access.rs +430 -0
  26. data/ext/parquet-core/tests/decimal_tests.rs +488 -0
  27. data/ext/parquet-core/tests/edge_cases_corner_cases.rs +322 -0
  28. data/ext/parquet-core/tests/error_handling_comprehensive_tests.rs +547 -0
  29. data/ext/parquet-core/tests/null_handling_tests.rs +430 -0
  30. data/ext/parquet-core/tests/performance_memory.rs +181 -0
  31. data/ext/parquet-core/tests/primitive_types.rs +547 -0
  32. data/ext/parquet-core/tests/real_world_patterns.rs +777 -0
  33. data/ext/parquet-core/tests/roundtrip_correctness.rs +279 -0
  34. data/ext/parquet-core/tests/schema_comprehensive_tests.rs +534 -0
  35. data/ext/parquet-core/tests/temporal_tests.rs +518 -0
  36. data/ext/parquet-core/tests/test_helpers.rs +132 -0
  37. data/ext/parquet-core/tests/writer_tests.rs +545 -0
  38. data/ext/parquet-ruby-adapter/Cargo.toml +22 -0
  39. data/ext/parquet-ruby-adapter/build.rs +5 -0
  40. data/ext/parquet-ruby-adapter/examples/try_into_value_demo.rs +98 -0
  41. data/ext/parquet-ruby-adapter/src/batch_manager.rs +116 -0
  42. data/ext/parquet-ruby-adapter/src/chunk_reader.rs +237 -0
  43. data/ext/parquet-ruby-adapter/src/converter.rs +1685 -0
  44. data/ext/parquet-ruby-adapter/src/error.rs +148 -0
  45. data/ext/{parquet/src/ruby_reader.rs → parquet-ruby-adapter/src/io.rs} +190 -56
  46. data/ext/parquet-ruby-adapter/src/lib.rs +90 -0
  47. data/ext/parquet-ruby-adapter/src/logger.rs +64 -0
  48. data/ext/parquet-ruby-adapter/src/metadata.rs +427 -0
  49. data/ext/parquet-ruby-adapter/src/reader.rs +317 -0
  50. data/ext/parquet-ruby-adapter/src/schema.rs +810 -0
  51. data/ext/parquet-ruby-adapter/src/string_cache.rs +106 -0
  52. data/ext/parquet-ruby-adapter/src/try_into_value.rs +91 -0
  53. data/ext/parquet-ruby-adapter/src/types.rs +94 -0
  54. data/ext/parquet-ruby-adapter/src/utils.rs +186 -0
  55. data/ext/parquet-ruby-adapter/src/writer.rs +435 -0
  56. data/lib/parquet/schema.rb +19 -0
  57. data/lib/parquet/version.rb +1 -1
  58. metadata +50 -24
  59. data/ext/parquet/src/enumerator.rs +0 -68
  60. data/ext/parquet/src/header_cache.rs +0 -99
  61. data/ext/parquet/src/logger.rs +0 -171
  62. data/ext/parquet/src/reader/common.rs +0 -111
  63. data/ext/parquet/src/reader/mod.rs +0 -211
  64. data/ext/parquet/src/reader/parquet_column_reader.rs +0 -44
  65. data/ext/parquet/src/reader/parquet_row_reader.rs +0 -43
  66. data/ext/parquet/src/reader/unified/mod.rs +0 -363
  67. data/ext/parquet/src/types/core_types.rs +0 -120
  68. data/ext/parquet/src/types/mod.rs +0 -100
  69. data/ext/parquet/src/types/parquet_value.rs +0 -1275
  70. data/ext/parquet/src/types/record_types.rs +0 -603
  71. data/ext/parquet/src/types/schema_converter.rs +0 -290
  72. data/ext/parquet/src/types/schema_node.rs +0 -424
  73. data/ext/parquet/src/types/timestamp.rs +0 -285
  74. data/ext/parquet/src/types/type_conversion.rs +0 -1949
  75. data/ext/parquet/src/types/writer_types.rs +0 -329
  76. data/ext/parquet/src/utils.rs +0 -184
  77. data/ext/parquet/src/writer/mod.rs +0 -505
  78. data/ext/parquet/src/writer/write_columns.rs +0 -238
  79. data/ext/parquet/src/writer/write_rows.rs +0 -488
data/Cargo.toml CHANGED
@@ -1,3 +1,3 @@
1
1
  [workspace]
2
- members = ["./ext/parquet"]
2
+ members = ["./ext/parquet", "./ext/parquet-core", "./ext/parquet-ruby-adapter"]
3
3
  resolver = "2"
data/Gemfile CHANGED
@@ -11,6 +11,7 @@ group :development do
11
11
  # gem "benchmark-ips", "~> 2.12"
12
12
  # gem "polars-df"
13
13
  # gem "duckdb"
14
+ gem "benchmark-memory"
14
15
  end
15
16
 
16
17
  group :test do
data/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/parquet.svg)](https://badge.fury.io/rb/parquet)
4
4
 
5
- This project is a Ruby library wrapping the [parquet-rs](https://github.com/apache/parquet-rs) rust crate.
5
+ This project is a Ruby library wrapping the [`parquet`](https://github.com/apache/arrow-rs/tree/main/parquet) rust crate.
6
6
 
7
7
  ## Usage
8
8
 
9
- This library provides high-level bindings to parquet-rs with two primary APIs for reading Parquet files: row-wise and column-wise iteration. The column-wise API generally offers better performance, especially when working with subset of columns.
9
+ This library provides high-level bindings to `parquet` with two primary APIs for reading Parquet files: row-wise and column-wise iteration. The column-wise API generally offers better performance, especially when working with subset of columns.
10
10
 
11
11
  ### Metadata
12
12
 
@@ -264,9 +264,100 @@ The following data types are supported in the schema:
264
264
  - `binary`
265
265
  - `boolean`
266
266
  - `date32`
267
- - `timestamp_millis`, `timestamp_micros`
267
+ - `timestamp_millis`, `timestamp_micros`, `timestamp_second`, `timestamp_nanos`
268
268
  - `time_millis`, `time_micros`
269
269
 
270
+ ### Timestamp Timezone Handling
271
+
272
+ **CRITICAL PARQUET SPECIFICATION LIMITATION**: The Apache Parquet format specification only supports two types of timestamps:
273
+ 1. **UTC-normalized timestamps** (when ANY timezone is specified) - `isAdjustedToUTC = true`
274
+ 2. **Local/unzoned timestamps** (when NO timezone is specified) - `isAdjustedToUTC = false`
275
+
276
+ This means that specific timezone offsets like "+09:00" or "America/New_York" CANNOT be preserved in Parquet files. This is not a limitation of this Ruby library, but of the Parquet format itself.
277
+
278
+ **When Writing:**
279
+ - If the schema specifies ANY timezone (whether it's "UTC", "+09:00", "America/New_York", etc.):
280
+ - Time values are converted to UTC before storing
281
+ - The file metadata sets `isAdjustedToUTC = true`
282
+ - The original timezone information is LOST
283
+ - If the schema doesn't specify a timezone:
284
+ - Timestamps are stored as local/unzoned time (no conversion)
285
+ - The file metadata sets `isAdjustedToUTC = false`
286
+ - These represent "wall clock" times without timezone context
287
+
288
+ **When Reading:**
289
+ - If the Parquet file has `isAdjustedToUTC = true` (ANY timezone was specified during writing):
290
+ - Time objects are returned in UTC
291
+ - The original timezone (e.g., "+09:00") is NOT recoverable
292
+ - If the file has `isAdjustedToUTC = false` (NO timezone was specified):
293
+ - Time objects are returned as local time in your system's timezone
294
+ - These are "wall clock" times without timezone information
295
+
296
+ ```ruby
297
+ # Preferred approach: use has_timezone to be explicit about UTC vs local storage
298
+ schema = Parquet::Schema.define do
299
+ field :timestamp_utc, :timestamp_millis, has_timezone: true # Stored as UTC (default)
300
+ field :timestamp_local, :timestamp_millis, has_timezone: false # Stored as local/unzoned
301
+ field :timestamp_default, :timestamp_millis # Default: UTC storage
302
+ end
303
+
304
+ # Legacy approach still supported (any timezone value means UTC storage)
305
+ schema_legacy = Parquet::Schema.define do
306
+ field :timestamp_utc, :timestamp_millis, timezone: "UTC" # Stored as UTC
307
+ field :timestamp_tokyo, :timestamp_millis, timezone: "+09:00" # Also stored as UTC!
308
+ field :timestamp_local, :timestamp_millis # No timezone - local
309
+ end
310
+
311
+ # Time values will be converted based on schema
312
+ rows = [
313
+ [
314
+ Time.new(2024, 1, 1, 12, 0, 0, "+03:00"), # Converted to UTC if has_timezone: true
315
+ Time.new(2024, 1, 1, 12, 0, 0, "-05:00"), # Kept as local if has_timezone: false
316
+ Time.new(2024, 1, 1, 12, 0, 0) # Kept as local (default)
317
+ ]
318
+ ]
319
+
320
+ Parquet.write_rows(rows.each, schema: schema, write_to: "timestamps.parquet")
321
+
322
+ # Reading back - timezone presence determines UTC vs local
323
+ Parquet.each_row("timestamps.parquet") do |row|
324
+ # row["timestamp_utc"] => Time object in UTC
325
+ # row["timestamp_local"] => Time object in local timezone
326
+ # row["timestamp_default"] => Time object in local timezone
327
+ end
328
+
329
+ # If you need to preserve specific timezone information, store it separately:
330
+ schema_with_tz = Parquet::Schema.define do
331
+ field :timestamp, :timestamp_millis, has_timezone: true # Store as UTC
332
+ field :original_timezone, :string # Store timezone as string
333
+ end
334
+ ```
335
+
336
+ ## Architecture
337
+
338
+ This library uses a modular, trait-based architecture that separates language-agnostic Parquet operations from Ruby-specific bindings:
339
+
340
+ - **parquet-core**: Language-agnostic core functionality for Parquet file operations
341
+ - Pure Rust implementation without Ruby dependencies
342
+ - Traits for customizable I/O operations (`ChunkReader`) and value conversion (`ValueConverter`)
343
+ - Efficient Arrow-based reader and writer implementations
344
+
345
+ - **parquet-ruby-adapter**: Ruby-specific adapter layer
346
+ - Implements core traits for Ruby integration
347
+ - Handles Ruby value conversion through the `ValueConverter` trait
348
+ - Manages Ruby I/O objects through the `ChunkReader` trait
349
+
350
+ - **parquet gem**: Ruby FFI bindings
351
+ - Provides high-level Ruby API
352
+ - Manages memory safety between Ruby and Rust
353
+ - Supports both file-based and IO-based operations
354
+
355
+ This architecture enables:
356
+ - Clear separation of concerns between core functionality and language bindings
357
+ - Easy testing of core logic without Ruby dependencies
358
+ - Potential reuse of core functionality for other language bindings
359
+ - Type-safe interfaces through Rust's trait system
360
+
270
361
  ### Schema DSL for Complex Data Types
271
362
 
272
363
  In addition to the hash-based schema definition shown above, this library provides a more expressive DSL for defining complex schemas with nested structures:
@@ -11,16 +11,17 @@ rb-sys-env = "^0.2"
11
11
 
12
12
  [dependencies]
13
13
  ahash = "0.8"
14
- arrow-array = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan/fix-time" }
15
- arrow-buffer = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan/fix-time" }
16
- arrow-ipc = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan/fix-time", features = ["lz4"] }
17
- arrow-schema = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan/fix-time" }
14
+ arrow-array = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader" }
15
+ arrow-buffer = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader" }
16
+ arrow-ipc = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader", features = ["lz4"] }
17
+ arrow-schema = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader" }
18
18
  bytes = "^1.9"
19
19
  either = "1.9"
20
20
  itertools = "^0.14"
21
21
  jiff = "0.2"
22
22
  magnus = { version = "0.7", features = ["rb-sys"] }
23
- parquet = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan/fix-time", features = ["json"] }
23
+ parquet = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader", features = ["json"] }
24
+ parquet-ruby-adapter = { path = "../parquet-ruby-adapter" }
24
25
  rand = "0.9"
25
26
  rb-sys = "^0.9"
26
27
  simdutf8 = "0.1.5"
@@ -28,6 +29,8 @@ tempfile = "^3.15"
28
29
  thiserror = "2.0"
29
30
  num = "0.4.3"
30
31
  uuid = "1.16.0"
32
+ ordered-float = "5.0.0"
33
+
31
34
 
32
35
  [target.'cfg(target_os = "linux")'.dependencies]
33
36
  jemallocator = { version = "0.5", features = ["disable_initial_exec_tls"] }
@@ -0,0 +1,156 @@
1
+ use magnus::scan_args::{get_kwargs, scan_args};
2
+ use magnus::value::ReprValue;
3
+ use magnus::{Error as MagnusError, Ruby, Value};
4
+ use parquet_ruby_adapter::{
5
+ logger::RubyLogger, types::ParserResultType, utils::parse_parquet_write_args,
6
+ };
7
+ pub fn each_row(rb_self: Value, args: &[Value]) -> Result<Value, MagnusError> {
8
+ let ruby = Ruby::get().map_err(|_| {
9
+ MagnusError::new(
10
+ magnus::exception::runtime_error(),
11
+ "Failed to get Ruby runtime",
12
+ )
13
+ })?;
14
+
15
+ // Parse arguments
16
+ let parsed_args = scan_args::<(Value,), (), (), (), _, ()>(args)?;
17
+ let (to_read,) = parsed_args.required;
18
+
19
+ // Parse keyword arguments
20
+ let kwargs = get_kwargs::<
21
+ _,
22
+ (),
23
+ (
24
+ Option<Option<Value>>, // result_type
25
+ Option<Option<Vec<String>>>, // columns
26
+ Option<Option<bool>>, // strict
27
+ Option<Option<Value>>, // logger
28
+ ),
29
+ (),
30
+ >(
31
+ parsed_args.keywords,
32
+ &[],
33
+ &["result_type", "columns", "strict", "logger"],
34
+ )?;
35
+
36
+ let result_type: ParserResultType = if let Some(rt_value) = kwargs.optional.0.flatten() {
37
+ rt_value
38
+ .to_r_string()?
39
+ .to_string()?
40
+ .parse()
41
+ .map_err(|e| MagnusError::new(ruby.exception_arg_error(), e))?
42
+ } else {
43
+ ParserResultType::Hash
44
+ };
45
+ let columns = kwargs.optional.1.flatten();
46
+ let strict = kwargs.optional.2.flatten().unwrap_or(true);
47
+ let logger = RubyLogger::new(kwargs.optional.3.flatten())?;
48
+
49
+ // Delegate to parquet_ruby_adapter
50
+ parquet_ruby_adapter::reader::each_row(
51
+ &ruby,
52
+ rb_self,
53
+ to_read,
54
+ result_type,
55
+ columns,
56
+ strict,
57
+ logger,
58
+ )
59
+ }
60
+
61
+ pub fn each_column(rb_self: Value, args: &[Value]) -> Result<Value, MagnusError> {
62
+ let ruby = Ruby::get().map_err(|_| {
63
+ MagnusError::new(
64
+ magnus::exception::runtime_error(),
65
+ "Failed to get Ruby runtime",
66
+ )
67
+ })?;
68
+
69
+ // Parse arguments
70
+ let parsed_args = scan_args::<(Value,), (), (), (), _, ()>(args)?;
71
+ let (to_read,) = parsed_args.required;
72
+
73
+ // Parse keyword arguments
74
+ let kwargs = get_kwargs::<
75
+ _,
76
+ (),
77
+ (
78
+ Option<Option<Value>>, // result_type
79
+ Option<Option<Vec<String>>>, // columns
80
+ Option<Option<usize>>, // batch_size
81
+ Option<Option<bool>>, // strict
82
+ Option<Option<Value>>, // logger
83
+ ),
84
+ (),
85
+ >(
86
+ parsed_args.keywords,
87
+ &[],
88
+ &["result_type", "columns", "batch_size", "strict", "logger"],
89
+ )?;
90
+
91
+ let result_type: ParserResultType = if let Some(rt_value) = kwargs.optional.0.flatten() {
92
+ rt_value
93
+ .to_r_string()?
94
+ .to_string()?
95
+ .parse()
96
+ .map_err(|e| MagnusError::new(ruby.exception_arg_error(), e))?
97
+ } else {
98
+ ParserResultType::Hash
99
+ };
100
+ let columns = kwargs.optional.1.flatten();
101
+ let batch_size = if let Some(bs) = kwargs.optional.2.flatten() {
102
+ if bs == 0 {
103
+ return Err(MagnusError::new(
104
+ ruby.exception_arg_error(),
105
+ "batch_size must be greater than 0",
106
+ ));
107
+ }
108
+ Some(bs)
109
+ } else {
110
+ None
111
+ };
112
+ let strict = kwargs.optional.3.flatten().unwrap_or(true);
113
+ let logger = RubyLogger::new(kwargs.optional.4.flatten())?;
114
+
115
+ // Delegate to parquet_ruby_adapter
116
+ parquet_ruby_adapter::reader::each_column(
117
+ &ruby,
118
+ rb_self,
119
+ to_read,
120
+ result_type,
121
+ columns,
122
+ batch_size,
123
+ strict,
124
+ logger,
125
+ )
126
+ }
127
+
128
+ pub fn write_rows(args: &[Value]) -> Result<Value, MagnusError> {
129
+ let ruby = Ruby::get().map_err(|_| {
130
+ MagnusError::new(
131
+ magnus::exception::runtime_error(),
132
+ "Failed to get Ruby runtime",
133
+ )
134
+ })?;
135
+
136
+ // Parse arguments using the new parser
137
+ let write_args = parse_parquet_write_args(&ruby, args)?;
138
+
139
+ // Delegate to parquet_ruby_adapter
140
+ parquet_ruby_adapter::writer::write_rows(&ruby, write_args)
141
+ }
142
+
143
+ pub fn write_columns(args: &[Value]) -> Result<Value, MagnusError> {
144
+ let ruby = Ruby::get().map_err(|_| {
145
+ MagnusError::new(
146
+ magnus::exception::runtime_error(),
147
+ "Failed to get Ruby runtime",
148
+ )
149
+ })?;
150
+
151
+ // Parse arguments using the new parser
152
+ let write_args = parse_parquet_write_args(&ruby, args)?;
153
+
154
+ // Delegate to parquet_ruby_adapter
155
+ parquet_ruby_adapter::writer::write_columns(&ruby, write_args)
156
+ }
@@ -1,32 +1,24 @@
1
+ mod adapter_ffi;
1
2
  mod allocator;
2
- mod enumerator;
3
- pub mod header_cache;
4
- mod logger;
5
- mod reader;
6
- mod ruby_reader;
7
- mod types;
8
- mod utils;
9
- mod writer;
10
3
 
11
- use crate::enumerator::*;
12
- use crate::reader::*;
13
- use crate::types::*;
4
+ use magnus::{function, method, Error, Ruby};
14
5
 
15
- use magnus::{Error, Ruby};
16
- use writer::write_columns;
17
- use writer::write_rows;
6
+ use crate::adapter_ffi::{each_column, each_row, write_columns, write_rows};
7
+ use parquet_ruby_adapter::metadata::parse_metadata;
18
8
 
19
9
  /// Initializes the Ruby extension and defines methods.
20
10
  #[magnus::init]
21
11
  fn init(ruby: &Ruby) -> Result<(), Error> {
22
- // Require 'time' for Time.parse method
23
12
  ruby.require("time")?;
24
-
13
+ ruby.require("bigdecimal")?;
14
+
25
15
  let module = ruby.define_module("Parquet")?;
26
- module.define_module_function("metadata", magnus::method!(reader::parse_metadata, -1))?;
27
- module.define_module_function("each_row", magnus::method!(parse_parquet_rows, -1))?;
28
- module.define_module_function("each_column", magnus::method!(parse_parquet_columns, -1))?;
29
- module.define_module_function("write_rows", magnus::function!(write_rows, -1))?;
30
- module.define_module_function("write_columns", magnus::function!(write_columns, -1))?;
16
+
17
+ module.define_module_function("metadata", function!(parse_metadata, 1))?;
18
+ module.define_module_function("each_row", method!(each_row, -1))?;
19
+ module.define_module_function("each_column", method!(each_column, -1))?;
20
+ module.define_module_function("write_rows", function!(write_rows, -1))?;
21
+ module.define_module_function("write_columns", function!(write_columns, -1))?;
22
+
31
23
  Ok(())
32
24
  }
@@ -0,0 +1,23 @@
1
+ [package]
2
+ name = "parquet-core"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [dependencies]
7
+ arrow = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader" }
8
+ arrow-array = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader" }
9
+ arrow-buffer = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader" }
10
+ arrow-schema = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader" }
11
+ bytes = "1.5"
12
+ indexmap = "2.2"
13
+ jiff = "0.2"
14
+ num = "0.4.3"
15
+ ordered-float = "5.0.0"
16
+ parquet = { git = "https://github.com/njaremko/arrow-rs", branch = "nathan_06-24-remove_primitive_map_key_assertion_on_record_reader", features = ["arrow", "zstd", "lz4", "snap"] }
17
+ rand = "0.9.1"
18
+ serde = { version = "1.0", features = ["derive"] }
19
+ thiserror = "2.0"
20
+
21
+ [dev-dependencies]
22
+ uuid = { version = "1.0", features = ["v4"] }
23
+ tempfile = "3.8"