!=========================================== ! Author: Elmo Tempel (elmo.tempel@ut.ee) !=========================================== !> Reader lifecycle (open/close), filter/qc machinery, metadata population, !> row-group resolution, and the type-generic shared helpers every !> read-specifics child (parquet_read_numeric/_string/_temporal) reaches by !> host association: validity-buffer construction, reader/column/row-group !> existence checks, fixed-width text packing for the filter/qc C++ API, and !> the whole-column-read-avoidance row-group helpers. submodule (parquet_core) parquet_read use ieee_arithmetic, only: ieee_is_nan ! The read-time sort BUILDS ITS PERMUTATION HERE, with the same radix engine pf_argsort gives ! every other caller -- so `parquet_open_reader(..., sort_by=)`, `parquet_reader_set_sort` and ! `parquet_table%sort_by` all run one comparator, and cannot disagree about null placement, NaN ! placement or tie order. The C++ side still owns the decode and the Arrow type reduction; only ! the ordering moved. parquet_sorting does not use parquet_core, so this import is acyclic. ! iso_c_binding is NOT imported here: parquet_core does an unrestricted `use iso_c_binding`, ! so c_loc/c_null_ptr/c_int/c_int8_t all arrive by host association, and naming them again is ! a symbol conflict rather than a clarification. use parquet_sorting, only: pf_sort_keys, pf_argsort ! The row sample DRAWS ITS OWN MASK HERE, from the library's own counter-based generator, so ! that parquet_open_reader(..., sample_fraction=) and a caller's own pf_random_* draws come ! from one specified, tested generator instead of two. parquet_random is a leaf importing only ! iso_fortran_env, so this import is acyclic and costs the graph three files; see ! parquet_sample_algorithm (parquet_core.f90) for the mapping it composes. use parquet_random, only: pf_random_key, pf_random_fill_draws, pf_random_seed use iso_c_binding use iso_fortran_env, only: int8, int32, int64, real64 use parquet_bindings use parquet_settings, only: parquet_get_default_use_threads, parquet_push_settings_to_cpp use parquet_maml_base, only: parquet_maml_file use parquet_strings, only: parquet_string_column use parquet_temporal, only: parquet_date, parquet_time, parquet_timestamp, parquet_unit_seconds, parquet_unit_millis, & parquet_unit_micros, parquet_unit_nanos, parquet_ns_per_sec implicit none !> Expression-node kinds in the postfix (RPN) node list a parsed filter becomes: one LEAF per !> clause, plus the boolean combinators. These values are part of the bind(C) contract -- the !> C++ evaluator (parquet_reader_set_filter in parquet_wrapper.cpp) switches on exactly these !> numbers, so changing one means changing both sides. integer, parameter :: ND_LEAF = 1 integer, parameter :: ND_AND = 2 integer, parameter :: ND_OR = 3 integer, parameter :: ND_NOT = 4 !> Fixed widths of the three packed per-leaf string arrays crossing the bind(C) boundary !> (see pack_fixed_width_strings). Declared once here and host-associated to !> parquet_read_filter, so the parser and the packer cannot disagree about them; the raw rule !> text itself never crosses the boundary, so it is not constrained by these. integer, parameter :: filter_leaf_name_len = 64 integer, parameter :: filter_leaf_op_len = 16 integer, parameter :: filter_leaf_value_len = 512 !> How many uniforms parquet_apply_sample materialises at a time while building its row mask. !> !! A bound on SCRATCH, never on the answer. `pf_random_fill_draws` is a prefix-consistent view !! of one stream -- filling `v(1:3)` alone gives the first three of `v(1:6)` -- so chunking at !! any size yields the identical mask. That is `parquet_sample_algorithm`'s "keep(r) depends !! only on (seed, r)" applied to this loop, and it is why the constant may be changed freely. !! 1024 `real64` is 8 KiB: small enough to stay an ordinary automatic array, large enough that !! the per-call overhead of the fill disappears against the per-row work. integer, parameter :: sample_fill_chunk = 1024 !> Fixed width of the packed sort-key column-name array crossing the bind(C) boundary, the !> sort counterpart of filter_leaf_name_len above. Declared once here and host-associated to !> parquet_read_sort, so the parser and the packer cannot disagree about it. integer, parameter :: sort_key_name_len = 64 !> The `row_group_lo` value meaning "the caller named no row-group range at all", passed by !> parquet_open_reader(..., filter=) and by the two-argument parquet_reader_set_filter. !! !! Part of the bind(C) contract: parquet_reader_set_filter in parquet_wrapper.cpp tests !! `rg_lo != -1` to choose between the whole-file engine (filter columns decoded once, in one !! batched pass, and left in the column cache) and the bounded-memory engine (row group at a !! time, nothing cached). The distinction cannot be made from the VALUE of a row-group range, !! because 0 is a legitimate one: it means "all row groups", the bounded-memory engine over the !! whole file. Every caller that does supply a range goes through clamp_row_group_lo, so this !! value can only ever originate here. integer(int64), parameter :: no_row_group_scope = -1_int64 ! ---- Sort key parsing (parquet_read_sort) ---- interface !> Re-renders the whole parsed key list in canonical form ("ra asc, dec desc"), for !> parquet_reader_print_stat's own "sort:" line. Never parsed again by anything. module subroutine parquet_render_sort_keys(key_name, descending, nulls_first, nkeys, text) character(len=sort_key_name_len), intent(in) :: key_name(:) !! per-key column name. integer(int8), intent(in) :: descending(:) !! per key: 1 for descending. integer(int8), intent(in) :: nulls_first(:) !! per key: 1 to place nulls first. integer, intent(in) :: nkeys !! keys in use. character(len=:), allocatable, intent(out) :: text !! rendered key list. end subroutine parquet_render_sort_keys end interface ! ---- Filter expression parsing (parquet_read_filter) ---- interface !> Parses one parquet_filter%add rule (a clause, or clauses combined with and/or/not and !> parentheses) and APPENDS its postfix node list plus one packed leaf per clause to the !> accumulators, which several calls therefore build up together. `nnodes`/`nleaves` say !> how much of each (over-allocated) accumulator is in use. Purely syntactic: reports a !> parse failure via ok/errmsg -- never aborts, so the caller can attach the reader's !> file context to the message -- and leaves every schema-dependent check (column exists, !> value suits its type) to the C++ side. module subroutine parquet_parse_filter_expr(rule, node_kind, node_leaf, nnodes, leaf_name, leaf_op, & leaf_value, leaf_is_string, nleaves, ok, errmsg) character(len=*), intent(in) :: rule !! raw filter expression text from one %add call. integer(int8), allocatable, intent(inout) :: node_kind(:) !! ND_* kind per node, appended to. integer(int32), allocatable, intent(inout) :: node_leaf(:) !! 1-based leaf index per ND_LEAF node, else 0. integer, intent(inout) :: nnodes !! nodes in use; grows by this rule's own node count. character(len=filter_leaf_name_len), allocatable, intent(inout) :: leaf_name(:) !! per-leaf column name. character(len=filter_leaf_op_len), allocatable, intent(inout) :: leaf_op(:) !! per-leaf operator. character(len=filter_leaf_value_len), allocatable, intent(inout) :: leaf_value(:) !! per-leaf value, unquoted. integer(int8), allocatable, intent(inout) :: leaf_is_string(:) !! 1 if the value was double-quoted. integer, intent(inout) :: nleaves !! leaves in use; grows by this rule's own clause count. logical, intent(out) :: ok !! .true. if the rule parsed. character(len=:), allocatable, intent(out) :: errmsg !! parse-failure message; "" when ok. end subroutine parquet_parse_filter_expr !> Appends one operator node (ND_AND/ND_OR/ND_NOT) to an already-built node list -- how !> parquet_apply_filter AND-folds several %add rules into one expression without !> re-entering the parser. Reports the node-count cap via ok/errmsg, as above. module subroutine parquet_append_filter_node(kind, node_kind, node_leaf, nnodes, ok, errmsg) integer, intent(in) :: kind !! ND_AND, ND_OR or ND_NOT. integer(int8), allocatable, intent(inout) :: node_kind(:) !! ND_* kind per node, appended to. integer(int32), allocatable, intent(inout) :: node_leaf(:) !! 0 for an operator node. integer, intent(inout) :: nnodes !! nodes in use; incremented on success. logical, intent(out) :: ok !! .true. if the node fit within filter_max_nodes. character(len=:), allocatable, intent(out) :: errmsg !! failure message; "" when ok. end subroutine parquet_append_filter_node !> Re-renders a parsed expression from its node list in canonical form ("(ra > 180 and !> dec <= 0) or id is_null"): one space between tokens, parentheses only where precedence !> needs them, values re-quoted where the source quoted them. Used for !> parquet_reader_print_stat's whole-expression line, which stays meaningful for an !> expression the per-column view cannot represent. module subroutine parquet_render_filter_expr(node_kind, node_leaf, nnodes, leaf_name, leaf_op, & leaf_value, leaf_is_string, nleaves, text) integer(int8), intent(in) :: node_kind(:) !! ND_* kind per node. integer(int32), intent(in) :: node_leaf(:) !! 1-based leaf index per ND_LEAF node, else 0. integer, intent(in) :: nnodes !! nodes in use. character(len=filter_leaf_name_len), intent(in) :: leaf_name(:) !! per-leaf column name. character(len=filter_leaf_op_len), intent(in) :: leaf_op(:) !! per-leaf operator. character(len=filter_leaf_value_len), intent(in) :: leaf_value(:) !! per-leaf value, unquoted. integer(int8), intent(in) :: leaf_is_string(:) !! 1 if the value was double-quoted. integer, intent(in) :: nleaves !! leaves in use. character(len=:), allocatable, intent(out) :: text !! the canonical rendering; "" if nnodes <= 0. end subroutine parquet_render_filter_expr end interface contains !> Allocates `valid_buf(n)` and points `valid_ptr` at it via c_loc only when !> the caller actually asked for null-tolerant reading (null_value and/or !> is_valid present); otherwise valid_ptr stays c_null_ptr so the C++ side !> keeps its default strict (error-on-null) behavior. subroutine make_valid_buf(want_report, n, valid_buf, valid_ptr) logical, intent(in) :: want_report !! .true. if null_value and/or is_valid was given by the caller. integer(int64), intent(in) :: n !! number of elements to allocate. integer(c_int8_t), allocatable, target, intent(out) :: valid_buf(:) !! int8 validity buffer backing valid_ptr. type(c_ptr), intent(out) :: valid_ptr !! c_loc(valid_buf), or c_null_ptr if want_report is .false. if (want_report) then ! Allocated even when n is 0, because every caller's `is_valid = valid_buf /= 0` is a ! whole-array assignment that needs an allocated (if zero-sized) source. allocate(valid_buf(n)) if (n > 0_int64) then valid_ptr = c_loc(valid_buf) else ! A ZERO-ROW read: `C_LOC` requires a nonzero-sized array (F2018 18.2.3.6), so the ! obvious `c_loc(valid_buf)` here is not conforming -- nagfor's -C=pointer rejects ! it at run time ("Argument VALID_BUF to C_LOC is a zero-sized array") while ! gfortran accepts it silently. A null pointer says the same thing anyway: there is ! no element for C++ to report validity for, and the zero-sized assignment above ! is a no-op either way. valid_ptr = c_null_ptr end if else valid_ptr = c_null_ptr end if end subroutine make_valid_buf !> Every parquet_read_column variant calls this first: reader%handle is !> c_null_ptr until parquet_open_reader sets it, and every C++ entry point !> dereferences the handle immediately (see ConcurrencyGuard in !> parquet_wrapper.cpp) with no null check of its own -- calling in with an !> unopened reader previously crashed with an unhelpful SIGSEGV instead of !> a clean, diagnosable error. subroutine check_reader_open(reader, context) type(parquet_reader), intent(in) :: reader !! reader to check. character(len=*), intent(in) :: context !! calling procedure's name, used in the error-stop message. if (.not. c_associated(reader%handle)) then error stop trim(context) // ": reader has not been opened (call parquet_open_reader first)" end if end subroutine check_reader_open !> "" if reader%filename was never set; otherwise " (file: X)". Every !> caller of this is reached only once the reader is open, at which !> point parquet_open_reader has already set %filename, so this is !> effectively always populated in practice -- the allocated() guard is !> just defensive. Appended to post-open read errors so they name which !> parquet file failed, without needing that file to be threaded through !> every intermediate call. subroutine reader_filename_suffix(reader, suffix) type(parquet_reader), intent(in) :: reader !! reader whose filename is reported. character(len=:), allocatable, intent(out) :: suffix !! " (file: X)"-style suffix, or "". suffix = "" if (allocated(reader%filename)) then if (len_trim(reader%filename) > 0) suffix = " (file: " // trim(reader%filename) // ")" end if end subroutine reader_filename_suffix !> Every reader-taking procedure that also names a specific column calls !> this right after check_reader_open: an unrecognized column name used !> to reach the C++ side's own "Column not found" exception uncaught !> (get_single_chunk_array/get_column_index in parquet_wrapper.cpp), !> crashing with an unhelpful SIGABRT instead of a clean, diagnosable !> error -- the same class of bug parquet_prefetch_columns already !> guarded against (it validates every name up front for the same !> reason). Reuses parquet_reader_has_column, the same non-throwing !> schema lookup parquet_prefetch_columns uses. subroutine check_column_exists(reader, name, context) type(parquet_reader), intent(in) :: reader !! open reader to check against. character(len=*), intent(in) :: name !! column name to look up. character(len=*), intent(in) :: context !! calling procedure's name, used in the error-stop message. character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). if (parquet_reader_has_column(reader%handle, trim(name)//char(0)) == 0) then call reader_filename_suffix(reader, name_suffix) error stop trim(context) // ": column not found in parquet file: " // trim(name) // name_suffix end if end subroutine check_column_exists !> parquet_read_column_chunk's own extra guard, called right after check_column_exists: !> row_group must be within [1, num_row_groups], checked here (Fortran-side) rather than !> relying on Arrow's own ReadRowGroup bounds check, which throws an uncaught !> std::runtime_error (libc++abi terminate/SIGABRT with no diagnostic message reaching !> stderr cleanly) instead of this project's usual clean, diagnosable error_stop. subroutine check_row_group_valid(reader, row_group, context) type(parquet_reader), intent(in) :: reader !! open reader to check against. integer(int64), intent(in) :: row_group !! 1-based row group requested. character(len=*), intent(in) :: context !! calling procedure's name, used in the error-stop message. integer(int64) :: num_row_groups character(len=32) :: rg_str, total_str character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). num_row_groups = int(parquet_reader_get_num_row_groups(reader%handle), kind=int64) if (row_group < 1 .or. row_group > num_row_groups) then write(rg_str, '(i0)') row_group write(total_str, '(i0)') num_row_groups call reader_filename_suffix(reader, name_suffix) error stop trim(context) // ": row_group " // trim(rg_str) // " out of range (file has " // & trim(total_str) // " row group(s))" // name_suffix end if end subroutine check_row_group_valid !> Splits one parquet_filter%add rule ("<column> <op> [value]") into its !> three parts, purely by syntax -- no schema access here, so this cannot !> check that `name` is a real column or that `value` is well-formed for !> that column's actual type (parquet_reader_set_filter does that, once !> the schema is available). Deliberately NOT a general boolean-expression !> parser: exactly one clause per rule, no AND/OR/parens inside the string !> itself -- see the parquet_filter type's own doc comment. subroutine parquet_tokenize_filter_rule(rule, name, op, value, is_string, ok, errmsg) character(len=*), intent(in) :: rule !! raw "<column> <op> [value]" rule text. character(len=:), allocatable, intent(out) :: name !! parsed column name. character(len=:), allocatable, intent(out) :: op !! parsed operator. character(len=:), allocatable, intent(out) :: value !! parsed value (unquoted); "" if the operator takes none. character(len=:), allocatable, intent(out) :: errmsg !! parse-failure message; "" if ok is .true. logical, intent(out) :: is_string !! .true. if value was double-quoted in the source rule. logical, intent(out) :: ok !! .true. if rule parsed successfully. character(len=:), allocatable :: t, rest integer :: p ok = .false. is_string = .false. value = "" name = "" op = "" errmsg = "" t = trim(adjustl(rule)) if (len(t) == 0) then ! GCOVR_EXCL_START -- gcov attribution artifact errmsg = "empty filter rule" return end if ! GCOVR_EXCL_STOP p = index(t, " ") if (p == 0) then errmsg = "filter rule '" // t // "' is missing an operator" return end if name = t(1:p-1) rest = trim(adjustl(t(p+1:))) if (len(rest) == 0) then ! GCOVR_EXCL_START -- gcov attribution artifact errmsg = "filter rule '" // t // "' is missing an operator" return end if ! GCOVR_EXCL_STOP p = index(rest, " ") if (p == 0) then op = rest rest = "" else op = rest(1:p-1) rest = trim(adjustl(rest(p+1:))) end if select case (trim(op)) case ("is_null", "is_not_null", "is_nan", "is_not_nan") if (len(rest) > 0) then ! GCOVR_EXCL_START -- gcov attribution artifact errmsg = "filter rule '" // t // "': " // trim(op) // " takes no value" return end if ! GCOVR_EXCL_STOP case (">", ">=", "<", "<=", "==", "/=") if (len(rest) == 0) then ! GCOVR_EXCL_START -- gcov attribution artifact errmsg = "filter rule '" // t // "' is missing a value after '" // trim(op) // "'" return end if ! GCOVR_EXCL_STOP if (rest(1:1) == '"') then ! gcov attribution artifact: evaluated whenever a quoted value is seen, regardless of ! whether the closing quote is missing. if (len(rest) < 2 .or. rest(len(rest):len(rest)) /= '"') then ! GCOVR_EXCL_START errmsg = "filter rule '" // t // "' has an unterminated quoted value" return end if ! GCOVR_EXCL_STOP value = rest(2:len(rest)-1) is_string = .true. else value = rest is_string = .false. end if case default ! gcov attribution artifact: gfortran/gcov mis-attributes hit counts to this bare `return` ! from elsewhere in the subroutine's epilogue -- the errmsg assignment directly above it ! (same never-taken branch) reliably shows 0 hits, proving this arm is never actually reached. errmsg = "filter rule '" // t // "' has an unknown operator '" // trim(op) // "'" ! GCOVR_EXCL_LINE return ! GCOVR_EXCL_LINE -- gcov attribution artifact end select ok = .true. end subroutine parquet_tokenize_filter_rule !> Packs a fixed-width character array into the same "n fixed-width items !> back to back" convention used for names_packed elsewhere in this file !> (see parquet_prefetch_columns) -- shared here since parquet_apply_filter !> needs it for three separate arrays (names/ops/values). subroutine pack_fixed_width_strings(strs, packed) character(len=*), intent(in) :: strs(:) !! fixed-width strings to pack. character(kind=c_char), allocatable, intent(out) :: packed(:) !! flattened "n fixed-width items back to back" buffer. integer :: i, j, k, item_len, n item_len = len(strs) n = size(strs) allocate(packed(item_len * n)) k = 0 do i = 1, n do j = 1, item_len k = k + 1 packed(k) = achar(iachar(strs(i)(j:j)), kind=c_char) end do end do end subroutine pack_fixed_width_strings !> Builds this reader's Bernoulli(sample_fraction) row mask and hands it to the C++ side -- !> called from parquet_open_reader right after the reader itself is created and before any !> filter=/qc setup. sample_fraction must already be validated by the caller (not negative, not !> NaN, < 1.0); sample_seed <= 0 (or absent) settles a fresh entropy seed. The seed actually !> used is always reported back via parquet_reader_print_stat, whether caller-supplied or !> drawn, so a non-deterministic run's seed can be read back and reused later. !> !> **The draw happens here, in Fortran, not in C++**, and the mapping is the frozen one !> `parquet_sample_algorithm` (parquet_core.f90) states. Its one property everything else rests !> on: row r's keep/drop depends on nothing but `(seed, r)`. That is what makes the chunked !> fill below, a pruned row group, and the deferral described next all incapable of moving a !> single decision -- and it is why the C++ side can simply index the mask by physical row. !> !> filter_will_follow must be .true. iff parquet_open_reader_base is also about to call !> parquet_apply_filter right after this (i.e. present(filter) .and. filter%n > 0) -- when it !> is, the C++ side holds the mask rather than installing it, so the filter's own clause !> evaluation still sees raw, unmasked column data; installing it immediately would otherwise !> make those columns come back already sample-compacted mid-evaluation (see !> parquet_reader_set_sample's own comment in parquet_wrapper.cpp for the crash this avoids). !> WHICH rows are selected is identical either way; the deferral is about ordering alone. subroutine parquet_apply_sample(reader, sample_fraction, sample_seed, filter_will_follow) type(parquet_reader), intent(inout) :: reader !! open reader gaining the sample mask. real(real64), intent(in) :: sample_fraction !! fraction of rows to keep, already validated to be in [0.0, 1.0). integer(int64), intent(in), optional :: sample_seed !! >0 for a reproducible draw; absent/<=0 settles a fresh one. logical, intent(in) :: filter_will_follow !! .true. iff parquet_apply_filter also runs right after this. integer(int64) :: seed_used, key, nrows, lo, n, k integer(c_int8_t), allocatable :: keep(:) real(real64) :: u(sample_fill_chunk) character(len=1024) :: c_err integer(c_long_long) :: status character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). ! A fraction of exactly 0.0 keeps nothing whatever the seed is, so it settles no seed and ! reports 0 -- long-documented behaviour, and what makes "there are no rows to reproduce" ! exactly true. Note this is a SEPARATE special case from the draw itself, which needs ! none: u is in [0, 1), so `u < 0.0` is never true and the loop below would keep nothing ! anyway. Do not fold the two together. seed_used = 0_int64 if (sample_fraction > 0.0_real64) then ! `sample_seed <= 0` is parquet_open_reader's own spelling of "settle a fresh one", and ! pf_random_seed is always in [1, huge(int64)], so a settled seed can never read back ! through sample_seed= as "no seed". seed_used = pf_random_seed() if (present(sample_seed)) then if (sample_seed > 0_int64) seed_used = sample_seed end if end if nrows = int(parquet_reader_get_total_nrows(reader%handle), int64) ! max(.., 1) only so the actual argument is never zero-sized: keep_len still says 0, and ! the C++ side then reads nothing. A zero-sized actual passed to an assumed-size dummy is ! what nagfor's -C=array objects to, and it costs one byte to avoid. allocate(keep(max(nrows, 1_int64))) keep = 0_c_int8_t if (sample_fraction > 0.0_real64 .and. nrows > 0_int64) then key = pf_random_key(seed_used, parquet_sample_label) lo = 1_int64 do while (lo <= nrows) n = min(int(sample_fill_chunk, int64), nrows - lo + 1_int64) ! Row r is draw r of stream 0. The fill is a prefix-consistent view of that one ! stream, so this chunk boundary cannot change any element -- see ! sample_fill_chunk's own note. call pf_random_fill_draws(key, 0_int64, u(1:n), lo) do k = 1, n if (u(k) < sample_fraction) keep(lo + k - 1_int64) = 1_c_int8_t end do lo = lo + n end do end if c_err = "" status = parquet_reader_set_sample(reader%handle, real(sample_fraction, kind=c_double), & int(seed_used, kind=c_int64_t), keep, int(nrows, kind=c_int64_t), & merge(1_c_int8_t, 0_c_int8_t, filter_will_follow), c_err, int(len(c_err), kind=c_long_long)) if (status /= 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_open_reader: " // trim(c_err) // name_suffix end if end subroutine parquet_apply_sample !> Validates+parses `maml` (parquet_parse_qc_maml) and hands the !> resulting per-column qc: rules to parquet_reader_set_qc -- called from !> parquet_open_reader BEFORE parquet_apply_filter, so that any column !> the filter itself touches while evaluating its clauses is already !> covered by qc (see run_qc_checks/parquet_reader_set_filter in !> parquet_wrapper.cpp). A field with no qc: block at all is already !> excluded by parquet_parse_qc_maml, so every entry in `rules` here !> really does need to reach parquet_reader_set_qc. subroutine parquet_apply_qc(reader, maml, qc_soft) type(parquet_reader), intent(inout) :: reader !! open reader gaining qc: enforcement. type(parquet_maml_file), intent(in) :: maml !! schema/qc-maml whose qc: rules are applied. logical, intent(in) :: qc_soft !! .true. warns on a qc violation instead of error-stopping. type(parquet_qc_rule), allocatable :: rules(:) character(len=64), allocatable :: names(:), min_ops(:), max_ops(:) character(len=256), allocatable :: min_texts(:), max_texts(:) integer(c_int8_t), allocatable :: has_min_flags(:), has_max_flags(:), null_allowed_flags(:) character(kind=c_char), allocatable :: names_packed(:), min_ops_packed(:), max_ops_packed(:) character(kind=c_char), allocatable :: min_texts_packed(:), max_texts_packed(:) integer :: i, n call parquet_parse_qc_maml(maml, rules) n = size(rules) allocate(names(max(n, 1)), min_ops(max(n, 1)), max_ops(max(n, 1))) allocate(min_texts(max(n, 1)), max_texts(max(n, 1))) allocate(has_min_flags(max(n, 1)), has_max_flags(max(n, 1)), null_allowed_flags(max(n, 1))) do i = 1, n names(i) = rules(i)%name has_min_flags(i) = merge(1_c_int8_t, 0_c_int8_t, rules(i)%has_min) min_ops(i) = rules(i)%min_op min_texts(i) = rules(i)%min_text has_max_flags(i) = merge(1_c_int8_t, 0_c_int8_t, rules(i)%has_max) max_ops(i) = rules(i)%max_op max_texts(i) = rules(i)%max_text null_allowed_flags(i) = merge(1_c_int8_t, 0_c_int8_t, rules(i)%null_values_allowed) end do call pack_fixed_width_strings(names, names_packed) call pack_fixed_width_strings(min_ops, min_ops_packed) call pack_fixed_width_strings(min_texts, min_texts_packed) call pack_fixed_width_strings(max_ops, max_ops_packed) call pack_fixed_width_strings(max_texts, max_texts_packed) call parquet_reader_set_qc(reader%handle, names_packed, int(len(names), kind=c_long_long), & has_min_flags, min_ops_packed, int(len(min_ops), kind=c_long_long), & min_texts_packed, int(len(min_texts), kind=c_long_long), & has_max_flags, max_ops_packed, int(len(max_ops), kind=c_long_long), & max_texts_packed, int(len(max_texts), kind=c_long_long), & null_allowed_flags, int(n, kind=c_long_long), merge(1_c_int8_t, 0_c_int8_t, qc_soft)) end subroutine parquet_apply_qc !> Tokenizes and applies every rule in `filter` to `reader` -- called from !> parquet_open_reader right after the reader itself is created, so !> parquet_get_nrows and every column read afterward already reflect the !> filtered row set (see parquet_reader_set_filter in parquet_wrapper.cpp !> for the actual validation/masking). subroutine parquet_apply_filter(reader, filter, context, row_group_lo, row_group_hi, row_lo, row_hi) type(parquet_reader), intent(inout) :: reader !! open reader the filter is applied to. type(parquet_filter), intent(in) :: filter !! filter whose rules are parsed, validated, and applied. character(len=*), intent(in) :: context !! calling procedure's name, used in every error-stop message. integer(int64), intent(in) :: row_group_lo !! first row group, 0 for all of them, or no_row_group_scope. integer(int64), intent(in) :: row_group_hi !! last row group; ignored when row_group_lo is 0. integer(int64), intent(in) :: row_lo !! first physical row that may match, or 0 for no row bound. integer(int64), intent(in) :: row_hi !! last physical row that may match, or 0 for no row bound. integer(int8), allocatable :: node_kind(:), leaf_is_string(:) integer(int32), allocatable :: node_leaf(:) character(len=filter_leaf_name_len), allocatable :: leaf_name(:) character(len=filter_leaf_op_len), allocatable :: leaf_op(:) character(len=filter_leaf_value_len), allocatable :: leaf_value(:) character(kind=c_char), allocatable :: names_packed(:), ops_packed(:), values_packed(:) character(len=:), allocatable :: errmsg, expr_text logical :: ok character(len=1024) :: c_err integer(c_long_long) :: status integer :: i, nnodes, nleaves character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). nnodes = 0 nleaves = 0 do i = 1, filter%n call parquet_parse_filter_expr(filter%rules(i), node_kind, node_leaf, nnodes, leaf_name, & leaf_op, leaf_value, leaf_is_string, nleaves, ok, errmsg) if (.not. ok) then call reader_filename_suffix(reader, name_suffix) error stop trim(context) // ": invalid filter rule: " // errmsg // name_suffix end if ! Several %add calls are AND-combined -- (expr1) and (expr2) and ... -- so every rule ! after the first folds onto whatever is already on the stack. if (i > 1) then call parquet_append_filter_node(ND_AND, node_kind, node_leaf, nnodes, ok, errmsg) if (.not. ok) then call reader_filename_suffix(reader, name_suffix) error stop trim(context) // ": invalid filter rule: " // errmsg // name_suffix end if end if end do ! A rule-less filter still has something to install when a row-group scope or a physical ! row range was given: an all-true-within-range mask, which is how a slice-regime table ! carrying only sample_fraction= expresses its own bounds. With no rules AND no bounds ! there is genuinely nothing to do. `row_group_lo <= 0` covers both no_row_group_scope ! (-1, no range named) and 0 (all row groups): with no clauses, "every row of every row ! group" is what an absent filter already gives, whichever engine would have run. if (nleaves == 0 .and. row_group_lo <= 0 .and. row_group_hi <= 0 .and. & row_lo <= 0 .and. row_hi <= 0) return ! A rule-less filter (nleaves == nnodes == 0) never enters the loop above, so ! parquet_parse_filter_expr never gets a chance to allocate these -- but the row-bound-only ! path above still falls through to the array-section references below, and referencing ! even a zero-trip section of an unallocated allocatable is invalid (ifx's runtime checks ! catch this; gfortran silently tolerates it). if (.not. allocated(node_kind)) allocate(node_kind(0), node_leaf(0)) if (.not. allocated(leaf_name)) allocate(leaf_name(0), leaf_op(0), leaf_value(0), leaf_is_string(0)) call convert_temporal_filter_values(reader, context, leaf_name, leaf_op, leaf_value, leaf_is_string, nleaves) call parquet_render_filter_expr(node_kind, node_leaf, nnodes, leaf_name, leaf_op, leaf_value, & leaf_is_string, nleaves, expr_text) call pack_fixed_width_strings(leaf_name(1:nleaves), names_packed) call pack_fixed_width_strings(leaf_op(1:nleaves), ops_packed) call pack_fixed_width_strings(leaf_value(1:nleaves), values_packed) c_err = "" status = c_reader_set_filter(reader%handle, names_packed, int(filter_leaf_name_len, kind=c_long_long), & ops_packed, int(filter_leaf_op_len, kind=c_long_long), values_packed, & int(filter_leaf_value_len, kind=c_long_long), leaf_is_string(1:nleaves), & int(nleaves, kind=c_long_long), node_kind(1:nnodes), node_leaf(1:nnodes), & int(nnodes, kind=c_long_long), expr_text//char(0), row_group_lo, row_group_hi, & row_lo, row_hi, c_err, int(len(c_err), kind=c_long_long)) call reader_filename_suffix(reader, name_suffix) if (status /= 0) error stop trim(context) // ": " // trim(c_err) // name_suffix end subroutine parquet_apply_filter !> Parses every key of `sort_by`, packs them into the fixed-width arrays the bind(C) boundary !> carries, and installs the sort -- called from parquet_open_reader(..., sort_by=) and from !> parquet_reader_set_sort, which is why the abort messages take their `context` from the !> caller. Mirrors parquet_apply_filter's shape exactly. !> !> Runs AFTER any filter/sample mask is installed, which is what makes "filter first, then !> sort within the survivors" true: each key column is read through the normal path, so it !> arrives already filtered, and the permutation covers the surviving rows only. subroutine parquet_apply_sort(reader, sort_by, context, keep_cache) type(parquet_reader), intent(inout) :: reader !! open reader the sort is applied to. type(parquet_sortkey), intent(in) :: sort_by !! keys to parse, validate and apply. character(len=*), intent(in) :: context !! calling procedure's name, used in every error-stop message. logical, intent(in), optional :: keep_cache !! .true. keeps the decoded key columns, sorted in place. character(len=sort_key_name_len), allocatable :: key_name(:) integer(int8), allocatable :: descending(:), nulls_first(:) character(len=:), allocatable :: name, errmsg, key_text, name_suffix logical :: ok, desc character(len=1024) :: c_err integer(c_long_long) :: status integer :: i type(pf_sort_keys) :: skeys integer(int64), allocatable :: perm(:) integer(int64) :: nrows integer(c_long_long) :: keep if (sort_by%n == 0) return ! Default 0: the key columns this sort is about to decode are RELEASED once the permutation ! exists, so nothing pays to reorder a column the caller may never read. Only an open that ! also prefetches asks for them to be kept -- see parquet_reader_sort_install's own note. keep = 0_c_long_long if (present(keep_cache)) then if (keep_cache) keep = 1_c_long_long end if allocate(key_name(sort_by%n), descending(sort_by%n), nulls_first(sort_by%n)) do i = 1, sort_by%n call parquet_parse_sort_key(sort_by%keys(i), name, desc, ok, errmsg) if (.not. ok) then call reader_filename_suffix(reader, name_suffix) error stop trim(context) // ": invalid sort key: " // errmsg // name_suffix end if key_name(i) = name descending(i) = merge(1_int8, 0_int8, desc) nulls_first(i) = merge(1_int8, 0_int8, sort_by%nulls_first(i)) end do call parquet_render_sort_keys(key_name, descending, nulls_first, sort_by%n, key_text) call reader_filename_suffix(reader, name_suffix) ! Every key is pulled across, reduced, and appended in order of precedence; the C++ side ! still owns the decode and the Arrow type reduction, so which columns are sortable and ! what a refusal says are unchanged by the engine swap. nrows = -1_int64 do i = 1, sort_by%n call add_read_sort_key(reader, trim(key_name(i)), descending(i), nulls_first(i), & trim(context) // name_suffix, skeys, nrows) end do allocate(perm(nrows)) call pf_argsort(skeys, perm) ! pf_argsort produces 1-based indices; Arrow's Take consumes 0-based ones. Done here rather ! than in C++ so the conversion sits next to the call that creates the obligation. perm = perm - 1_int64 c_err = "" status = parquet_reader_sort_install(reader%handle, perm, int(nrows, kind=c_long_long), & key_text//char(0), keep, c_err, int(len(c_err), kind=c_long_long)) if (status /= 0) error stop trim(context) // ": " // trim(c_err) // name_suffix end subroutine parquet_apply_sort !> Pulls one read-time sort key across the bind(C) boundary and appends it to `skeys`. !> !> **Two calls per key, `_info` then `_fetch`.** Only the C++ side knows which of the three !> reduced families a column lands in -- boolean and every temporal type arrive as integers -- !> so the sizes have to come back before the buffers can exist. Both calls bind the key; the !> decode behind them does not repeat, because `get_single_chunk_array` serves the second from !> the reader's column cache. See `parquet_wrapper.cpp` for why that repeat is preferred over !> staging the bound key on the reader handle. !> !> **The local buffers do not have to outlive this call**: `%add` EXTRACTS into the key list's !> own `sort_key_buf` rather than retaining what it was handed. subroutine add_read_sort_key(reader, name, descending, nulls_first, context, skeys, nrows) type(parquet_reader), intent(in) :: reader !! open reader the key column is read from. character(len=*), intent(in) :: name !! key column, possibly a dotted struct-leaf path. integer(int8), intent(in) :: descending !! nonzero for descending order. integer(int8), intent(in) :: nulls_first !! nonzero to place this key's nulls first. character(len=*), intent(in) :: context !! caller's name plus file suffix, for messages. type(pf_sort_keys), intent(inout) :: skeys !! key list this key is appended to. integer(int64), intent(inout) :: nrows !! row count; set from the first key, then reused. integer(c_int) :: family integer(c_long_long) :: nk, nbytes, status integer(c_int8_t) :: has_nulls character(len=1024) :: c_err integer(int64), allocatable, target :: iv(:), off(:) real(real64), allocatable, target :: rv(:) integer(int8), allocatable, target :: valid(:) character(kind=c_char), allocatable, target :: dat(:) logical, allocatable :: mask(:) type(parquet_string_column) :: scol integer(int64) :: k logical :: desc, nlo desc = descending /= 0_int8 nlo = nulls_first /= 0_int8 c_err = "" status = parquet_reader_sort_key_info(reader%handle, name//char(0), descending, nulls_first, & family, nk, nbytes, has_nulls, c_err, int(len(c_err), kind=c_long_long)) if (status /= 0) error stop trim(context) // ": " // trim(c_err) if (nrows < 0_int64) nrows = int(nk, int64) ! Allocated unconditionally, because c_loc of an unallocated array is not a thing that can ! be passed; the C++ side writes it only when the key really carries nulls. One byte per ! row, against the alternative of branching the fetch call four ways. allocate(valid(max(nk, 1_c_long_long))) select case (family) case (0) allocate(iv(max(nk, 1_c_long_long))) status = parquet_reader_sort_key_fetch(reader%handle, name//char(0), descending, nulls_first, & c_loc(iv), c_null_ptr, c_null_ptr, c_null_ptr, c_loc(valid), c_err, & int(len(c_err), kind=c_long_long)) if (status /= 0) error stop trim(context) // ": " // trim(c_err) call build_valid_mask(valid, nk, has_nulls, mask) ! `mask` is unallocated when the key has no nulls, and an unallocated allocatable ! passed to an optional dummy is ABSENT (F2018 15.5.2.12) -- which is what puts the ! engine on its no-mask fast path instead of walking an all-true mask. call skeys%add(iv(1:nk), descending=desc, nulls_first=nlo, is_valid=mask) case (1) allocate(rv(max(nk, 1_c_long_long))) status = parquet_reader_sort_key_fetch(reader%handle, name//char(0), descending, nulls_first, & c_null_ptr, c_loc(rv), c_null_ptr, c_null_ptr, c_loc(valid), c_err, & int(len(c_err), kind=c_long_long)) if (status /= 0) error stop trim(context) // ": " // trim(c_err) call build_valid_mask(valid, nk, has_nulls, mask) call skeys%add(rv(1:nk), descending=desc, nulls_first=nlo, is_valid=mask) case default allocate(off(0:max(nk, 1_c_long_long)), dat(max(nbytes, 1_c_long_long))) status = parquet_reader_sort_key_fetch(reader%handle, name//char(0), descending, nulls_first, & c_null_ptr, c_null_ptr, c_loc(off), c_loc(dat), c_loc(valid), c_err, & int(len(c_err), kind=c_long_long)) if (status /= 0) error stop trim(context) // ": " // trim(c_err) ! Built through the buffer handoff rather than a character array, so a value's exact ! bytes survive: a fixed-width character array would blank-pad, and a string with real ! trailing spaces would then order as though it did not have them. call scol%append_buffers(int(nk, int64), int(nbytes, int64), c_loc(off), c_loc(dat), & c_null_ptr, .false.) ! `add_strcol` takes no is_valid -- a string column carries its own null state -- so ! the nulls go onto the column instead of alongside it. if (has_nulls /= 0_c_int8_t) then do k = 1_int64, int(nk, int64) if (valid(k) == 0_int8) call scol%set_null(k) end do end if call skeys%add(scol, descending=desc, nulls_first=nlo) end select end subroutine add_read_sort_key !> Turns the C++ side's int8 validity flags into the `logical` mask `%add` takes, leaving the !> result UNALLOCATED when the key carries no nulls -- see the call sites for why that matters. subroutine build_valid_mask(valid, nk, has_nulls, mask) integer(int8), intent(in) :: valid(:) !! per element: 0 marks a null. integer(c_long_long), intent(in) :: nk !! number of key elements. integer(c_int8_t), intent(in) :: has_nulls !! nonzero when the key carries any null. logical, allocatable, intent(out) :: mask(:) !! the mask, or unallocated when null-free. if (has_nulls == 0_c_int8_t) return allocate(mask(nk)) mask = valid(1:nk) /= 0_int8 end subroutine build_valid_mask !> Refuses any row-group-scoped operation while a read-time sort is active. A sort permutation !> destroys row-group locality outright -- sorted row 5 may come from row group 47 and row 6 !> from row group 3 -- so there is no coherent "row group N of the sorted output" to serve. !> !> This is NOT the guard the filter/sample mask once had, and the difference is the whole point !> (see reader_has_sort_permutation in parquet_wrapper.cpp): a mask only ever REMOVES rows, so !> row groups stay contiguous and every chunked read works under one. Only a permutation !> reorders. Keep every new "not while sorted" check keyed on this one predicate rather than !> testing the handle directly, so a future row transform cannot be added without the guards !> noticing it. subroutine check_reader_no_sort(reader, context) type(parquet_reader), intent(in) :: reader !! reader to check. character(len=*), intent(in) :: context !! calling procedure's name, used in the error-stop message. character(len=:), allocatable :: name_suffix if (parquet_reader_has_sort(reader%handle) /= 0) then call reader_filename_suffix(reader, name_suffix) error stop trim(context) // ": not supported on a reader with an active sort, since a sorted row " // & "can come from any row group; read the column whole (parquet_read_column) instead" // name_suffix end if end subroutine check_reader_no_sort !> Rewrites every temporal (date/time/timestamp) leaf's value from the ISO-8601 text the !> caller wrote into the raw integer that column actually stores, so the C++ evaluator can !> compare it directly against the column's own values -- no ISO parsing and no unit !> arithmetic on that side. Doing the conversion here reuses parquet_temporal's own tested !> parsers (rather than re-deriving days_from_civil in C++), and is the only place the !> column's stored unit and the literal's precision can be compared, which is what makes the !> precision check below possible at all. !> !> Leaves naming a non-temporal column, an unknown column (so the C++ side still reports it !> with its own message) or a column of a type outside the nine canonical tokens are left !> exactly as they were. !> !> Timezones are deliberately not interpreted: a parquet_timestamp holds the stored epoch !> offset verbatim (see parquet_temporal's own module comment), so the literal is read as a !> civil date/time and compared against the same stored instants a read would return. subroutine convert_temporal_filter_values(reader, context, leaf_name, leaf_op, leaf_value, leaf_is_string, nleaves) type(parquet_reader), intent(in) :: reader !! open reader the filter is being applied to. character(len=*), intent(in) :: context !! calling procedure's name, used in error-stop messages. character(len=filter_leaf_name_len), intent(in) :: leaf_name(:) !! per-leaf column name. character(len=filter_leaf_op_len), intent(in) :: leaf_op(:) !! per-leaf operator. character(len=filter_leaf_value_len), intent(inout) :: leaf_value(:) !! per-leaf value; rewritten in place. integer(int8), intent(inout) :: leaf_is_string(:) !! per-leaf "was quoted" flag; cleared once converted. integer, intent(in) :: nleaves !! leaves in use. character(len=:), allocatable :: name, op, type_name, text, name_suffix character(len=32) :: raw_str logical :: recognized, parse_ok integer :: i, unit integer(int64) :: raw do i = 1, nleaves name = trim(leaf_name(i)) op = trim(leaf_op(i)) ! Every valueless operator is skipped, not just the two null tests: is_nan/is_not_nan ! carry no literal to convert, and letting one through here would report a temporal ! column's missing ISO-8601 literal instead of the real reason (the C++ side rejects ! is_nan on any non-floating-point column, with a message naming that). if (op == "is_null" .or. op == "is_not_null" .or. op == "is_nan" .or. op == "is_not_nan") cycle if (parquet_reader_has_column(reader%handle, name//char(0)) == 0) cycle call resolve_column_type(reader, name, type_name, recognized) if (.not. recognized) cycle if (type_name /= "date" .and. type_name /= "time" .and. type_name /= "timestamp") cycle call reader_filename_suffix(reader, name_suffix) if (leaf_is_string(i) == 0_int8) then error stop trim(context) // ": filter rule: value '" // trim(leaf_value(i)) // "' for " // & type_name // " column '" // name // "' must be a double-quoted ISO-8601 literal (e.g. " // & '"2024-01-31", "12:30:00", "2024-01-31T12:30:00")' // name_suffix end if text = trim(leaf_value(i)) unit = parquet_unit_nanos ! Only a time/timestamp column has a stored unit; a date column is always whole days, ! and asking for its unit aborts inside parquet_reader_get_column_time_unit. if (type_name /= "date") unit = int(parquet_reader_get_column_time_unit(reader%handle, name//char(0))) call temporal_literal_to_raw(type_name, text, unit, raw, parse_ok) if (.not. parse_ok) then error stop trim(context) // ": filter rule: value '" // text // "' is not a valid ISO-8601 " // & type_name // " for column '" // name // "', or is more precise than that column's " // & "stored unit can represent" // name_suffix end if write(raw_str, '(i0)') raw leaf_value(i) = trim(raw_str) leaf_is_string(i) = 0_int8 end do end subroutine convert_temporal_filter_values !> Parses one ISO-8601 filter literal into the raw integer a `date`/`time`/`timestamp` column !> of stored unit `unit` holds: days since the epoch, unit-of-day, or units since the epoch. !> Reports failure (ok = .false.) both for an unparseable literal and for one carrying finer !> precision than `unit` can represent -- a literal with a time part against a date column, !> or sub-millisecond digits against a timestamp[ms] column. Truncating instead would silently !> answer a question the caller did not ask. subroutine temporal_literal_to_raw(type_name, text, unit, raw, ok) character(len=*), intent(in) :: type_name !! "date", "time" or "timestamp". character(len=*), intent(in) :: text !! the ISO-8601 literal, quotes already stripped. integer, intent(in) :: unit !! the column's stored unit (a parquet_unit_* selector). integer(int64), intent(out) :: raw !! the value as that column stores it. logical, intent(out) :: ok !! .true. if the literal parsed and fits the unit exactly. type(parquet_date) :: d type(parquet_time) :: t type(parquet_timestamp) :: ts integer(int64) :: ns_per_unit, ns, secs integer(int32) :: nanos raw = 0_int64 ok = .false. select case (type_name) case ("date") ! A date column stores whole days, so any time-of-day in the literal is unrepresentable ! -- parquet_date%parse rejects the "T..." form itself, which is exactly the wanted answer. call d%parse(text, ok) if (ok) raw = int(d%raw(), int64) case ("time") call t%parse(text, ok) if (.not. ok) return ns = t%raw() ns_per_unit = parquet_ns_per_sec/unit_scale_for(unit) ok = mod(ns, ns_per_unit) == 0_int64 if (ok) raw = ns/ns_per_unit case ("timestamp") call ts%parse(text, ok) if (.not. ok) then ! A date-only literal is a legal, less-precise way to name an instant: it means ! midnight of that date. (More precision than the column's unit is the case that ! is rejected, below -- less is not.) parquet_timestamp%parse itself requires a ! full date-time, so the date form is re-parsed here rather than there. call d%parse(text, ok) if (.not. ok) return call t%set(0, 0, 0) call ts%set(d, t) end if call ts%get_raw(secs, nanos) ns_per_unit = parquet_ns_per_sec/unit_scale_for(unit) ! Checked here rather than left to %to_unix's own precision abort, so the message can ! name the column and the rule instead of ending the process from inside parquet_temporal. ok = mod(int(nanos, int64), ns_per_unit) == 0_int64 if (ok) raw = ts%to_unix(unit) end select end subroutine temporal_literal_to_raw !> Units of one second for a parquet_unit_* selector (1 for seconds, 1000 for millis, ...) -- !> the same scale parquet_temporal applies internally, needed here to convert a parsed !> literal into a column's own stored unit. pure integer(int64) function unit_scale_for(unit) result(res) integer, intent(in) :: unit !! a parquet_unit_* selector. select case (unit) case (parquet_unit_seconds) ! GCOVR_EXCL_LINE -- unreachable: a seconds-unit time/timestamp ! column cannot exist. Parquet's physical format has no seconds-resolution TIME or ! TIMESTAMP encoding at all, so apply_temporal_unit_token rejects a time[s]/timestamp[s] ! MAML token outright and no file can present one here. Kept for completeness of the ! parquet_unit_* selector set. res = 1_int64 ! GCOVR_EXCL_LINE case (parquet_unit_millis) res = 1000_int64 case (parquet_unit_micros) res = 1000000_int64 case default res = 1000000000_int64 end select end function unit_scale_for !> Called once by parquet_open_reader, right after the handle is created: !> copies the file's flat key-value table metadata (whatever add_metadata !> wrote on the write side) into reader%metadata, so every later !> parquet_get_metadata call only scans this in-memory copy instead of !> re-reading the file. See parquet_reader_get_table_metadata_count and !> friends in parquet_bindings.f90/parquet_wrapper.cpp; `index` there is !> 0-based. subroutine populate_reader_metadata(reader) type(parquet_reader), intent(inout) :: reader !! open reader whose %metadata is populated. integer(c_long_long) :: count, klen, vlen, i character(len=:), allocatable :: kbuf, vbuf count = parquet_reader_get_table_metadata_count(reader%handle) if (count <= 0) return allocate(reader%metadata%items(int(count))) do i = 1, count klen = parquet_reader_get_table_metadata_key_length(reader%handle, i - 1) vlen = parquet_reader_get_table_metadata_value_length(reader%handle, i - 1) allocate(character(len=int(klen)) :: kbuf) allocate(character(len=int(vlen)) :: vbuf) if (klen > 0) call parquet_reader_get_table_metadata_key(reader%handle, i - 1, kbuf, klen) if (vlen > 0) call parquet_reader_get_table_metadata_value(reader%handle, i - 1, vbuf, vlen) reader%metadata%items(int(i))%key = kbuf reader%metadata%items(int(i))%value = vbuf deallocate(kbuf, vbuf) end do end subroutine populate_reader_metadata module procedure parquet_open_reader_base logical :: use_threads_value, qc_effective, qc_soft_value, filter_will_apply logical :: keep_sorted_cache !! whether the sort's key columns stay resident (prefetch only). character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). ! Refresh the C++ side's copy of every mirrored setting before any C++ state exists. ! No setter mirrors to C++ any more -- that is what let each knob's setter live beside its ! state in parquet_settings_base, where an Arrow-free module can re-export it -- so this is ! where the mirror is made current. See parquet_push_settings_to_cpp's own doc-comment. call parquet_push_settings_to_cpp() use_threads_value = parquet_get_default_use_threads() if (present(use_threads)) use_threads_value = use_threads reader%handle = create_parquet_reader(trim(filename)//char(0), merge(1_c_int, 0_c_int, use_threads_value)) reader%filename = trim(filename) call populate_reader_metadata(reader) filter_will_apply = .false. if (present(filter)) filter_will_apply = (filter%n > 0) ! Random downsampling (sample_fraction=): applied first, before any filter=/qc setup. ! filter_will_apply tells parquet_apply_sample whether parquet_apply_filter (below) is ! about to run right after this, so it can defer ! installing the draw as the reader's active mask until parquet_reader_set_filter folds it ! in -- see that subroutine's own doc-comment for why (installing it immediately here would ! make the filter's own referenced columns come back already sample-compacted mid-evaluation). ! NaN is checked first, before any relational comparison: NaN compares false against every ! threshold, so checking "< 0.0"/"< 1.0" first would let a NaN silently fall through as a ! no-op (same as >= 1.0) instead of reaching this error stop. if (present(sample_fraction)) then if (ieee_is_nan(sample_fraction)) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_open_reader: sample_fraction must not be NaN" // name_suffix else if (sample_fraction < 0.0_real64) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_open_reader: sample_fraction must not be negative" // name_suffix else if (sample_fraction < 1.0_real64) then call parquet_apply_sample(reader, sample_fraction, sample_seed, filter_will_apply) end if end if ! qc setup must happen before the filter is applied: the filter's own ! clause evaluation already counts as "touching" a column (see ! run_qc_checks/parquet_reader_set_filter in parquet_wrapper.cpp), so ! qc rules need to already be in place by then, not applied after. ! qc_soft defaults to .false. (hard: a violation aborts) and only ever ! matters when qc is on -- see run_qc_checks in parquet_wrapper.cpp. qc_effective = present(schema) if (present(qc)) qc_effective = qc qc_soft_value = .false. if (present(qc_soft)) qc_soft_value = qc_soft if (present(schema) .and. qc_effective) call parquet_apply_qc(reader, schema%maml, qc_soft_value) ! no_row_group_scope, not 0: an open-time filter names no row-group range, so it takes the ! whole-file engine and leaves its columns cached. A 0 here would ask for the ! bounded-memory, nothing-cached engine over all row groups instead -- see ! no_row_group_scope's own comment. if (filter_will_apply) call parquet_apply_filter(reader, filter, "parquet_open_reader", & no_row_group_scope, no_row_group_scope, 0_int64, 0_int64) ! Strictly after the filter: the sort orders the SURVIVING rows, so every key column has ! to arrive already masked (see parquet_apply_sort). Before the prefetch below, so a ! prefetched column is cached in sorted order rather than needing a second pass. ! keep_cache is the prefetch flag: with a prefetch coming, releasing the key columns would ! only make that prefetch decode them again. Nested rather than `.and.`-ed, because Fortran ! does not short-circuit and `prefetch` may be absent. if (present(sort_by)) then keep_sorted_cache = .false. if (present(prefetch)) keep_sorted_cache = prefetch call parquet_apply_sort(reader, sort_by, "parquet_open_reader", keep_cache=keep_sorted_cache) end if ! Must run AFTER parquet_apply_filter: a column cached before the ! filter mask exists would stay raw/unfiltered forever, since ! set_filter only re-masks the filter clauses' own columns, not the ! whole cache (see parquet_reader_prefetch_all_columns's own comment ! in parquet_wrapper.cpp). Filter columns are skipped here -- set_filter ! read and cached them itself, over the live row groups, and re-masked ! them -- so this only reads the remaining columns. if (present(prefetch)) then if (prefetch) call parquet_reader_prefetch_all_columns(reader%handle) end if end procedure parquet_open_reader_base !> The post-open counterpart of parquet_open_reader(..., filter=): same prefetch-then-apply !> sequence, same validation, same resulting reader state -- only the moment differs. Exists !> because a caller that does not own the parquet_open_reader call (a higher-level type that !> opens its own reader) otherwise has no way to filter at all. !> !> Both refusals below are checked before anything is read or mutated, so a rejected call !> leaves the reader exactly as it was: !> !> - An already-filtered reader. Its mask is indexed by the file's physical rows, while a !> second mask built now would be indexed by the surviving rows of the first, so composing !> them is not a matter of ANDing two vectors of the same length. Several %add calls on one !> parquet_filter are already AND-combined, which is what a caller wanting both should use. !> - A reader that has already decoded a column. That column was returned (or cached) over !> the unfiltered row set, and nothing read afterwards could be aligned with it. !> !> A reader opened with sample_fraction= is accepted: the sample mask is already installed, and !> these clauses AND onto it exactly as they would have at open time. module procedure parquet_reader_set_filter_base call parquet_reader_set_filter_impl(reader, filter, no_row_group_scope, no_row_group_scope, 0_int64, 0_int64) end procedure parquet_reader_set_filter_base module procedure parquet_reader_set_filter_scoped_int32 call parquet_reader_set_filter_impl(reader, filter, clamp_row_group_lo(int(row_group_lo, int64)), & int(row_group_hi, int64), 0_int64, 0_int64) end procedure parquet_reader_set_filter_scoped_int32 module procedure parquet_reader_set_filter_scoped_int64 call parquet_reader_set_filter_impl(reader, filter, clamp_row_group_lo(row_group_lo), row_group_hi, & 0_int64, 0_int64) end procedure parquet_reader_set_filter_scoped_int64 module procedure parquet_reader_set_filter_rows_int32 call parquet_reader_set_filter_impl(reader, filter, clamp_row_group_lo(int(row_group_lo, int64)), & int(row_group_hi, int64), int(row_lo, int64), int(row_hi, int64)) end procedure parquet_reader_set_filter_rows_int32 module procedure parquet_reader_set_filter_rows_int64 call parquet_reader_set_filter_impl(reader, filter, clamp_row_group_lo(row_group_lo), row_group_hi, & row_lo, row_hi) end procedure parquet_reader_set_filter_rows_int64 !> Maps any non-positive row-group lower bound onto 0 ("all row groups"), keeping the !> `no_row_group_scope` sentinel private to the forms that take no row-group arguments. !! !! This clamp is what makes the sentinel safe. Without it a caller passing -1 explicitly to the !! four-argument form would land on the *unscoped* path -- the whole-file, column-caching engine !! -- which is not what any negative bound means anywhere else in this library: every sibling !! procedure that takes a row-group range (parquet_measure_list_width, parquet_column_has_nulls) !! reads a non-positive lower bound as "all row groups". Here that answer is "all row groups, !! bounded-memory engine", because the arguments were supplied. pure function clamp_row_group_lo(row_group_lo) result(resolved) integer(int64), intent(in) :: row_group_lo !! caller's lower bound, possibly non-positive. integer(int64) :: resolved !! `row_group_lo`, or 0 when it was non-positive. resolved = row_group_lo if (resolved < 0_int64) resolved = 0_int64 end function clamp_row_group_lo !> The one implementation behind every parquet_reader_set_filter form. row_group_lo/hi are !> `no_row_group_scope` when the caller named no row-group range (the whole-file, caching !> engine), 0 for "all row groups" on the bounded-memory engine, and an inclusive 1-based range !> otherwise; row_lo/hi likewise bound the PHYSICAL rows that may match, or are 0 for no row !> bound. Every range is validated C++-side against the file's own row-group and row counts, !> including that a row range lies inside the rows its row groups span. subroutine parquet_reader_set_filter_impl(reader, filter, row_group_lo, row_group_hi, row_lo, row_hi) type(parquet_reader), intent(inout) :: reader !! open, unfiltered reader with no column decoded yet. type(parquet_filter), intent(in) :: filter !! filter whose rules are parsed, validated, and applied. integer(int64), intent(in) :: row_group_lo !! first row group, 0 for all of them, or no_row_group_scope. integer(int64), intent(in) :: row_group_hi !! last row group; ignored when row_group_lo is 0. integer(int64), intent(in) :: row_lo !! first physical row that may match, or 0 for no row bound. integer(int64), intent(in) :: row_hi !! last physical row that may match, or 0 for no row bound. character(len=:), allocatable :: name_suffix call check_reader_open(reader, "parquet_reader_set_filter") if (parquet_reader_has_filter_clauses(reader%handle) /= 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_reader_set_filter: this reader already has an active filter; combine the " // & "clauses into one parquet_filter instead (several %add calls are AND-combined)" // name_suffix end if ! BEFORE the decoded-columns guard below, deliberately. Ordering, not merely state: ! apply_row_transform (parquet_wrapper.cpp) masks first and permutes second, so a sort ! permutation's length is the POST-filter row count, and installing a filter under an ! existing sort would leave the two describing different row sets. **This check is now the ! ONLY one that refuses sort-then-filter**: it used to be backed up by the decoded-columns ! guard below, because applying a sort left its key columns in the cache, and ! parquet_reader_sort_install now releases them instead. That made a redundant guard into ! a load-bearing one -- do not reorder these two, and do not delete this one on the ! grounds that the next one would catch it. if (parquet_reader_has_sort(reader%handle) /= 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_reader_set_filter: this reader already has an active sort; apply the " // & "filter before the sort" // name_suffix end if if (parquet_reader_has_decoded_columns(reader%handle) /= 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_reader_set_filter: a column has already been read on this reader; a filter " // & "must be applied before any column is read" // name_suffix end if ! As for parquet_reader_set_sort: a chunked read leaves the column cache empty, so the ! guard above cannot see it. if (parquet_reader_has_chunk_reads(reader%handle) /= 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_reader_set_filter: a chunked read has already been done on this reader; a " // & "filter must be applied before any column is read" // name_suffix end if ! A rule-less filter still installs a mask when a row range was given (that range is the ! whole point of the call); with no rules and no range there is nothing to do. if (filter%n == 0 .and. row_lo <= 0 .and. row_hi <= 0) return call parquet_apply_filter(reader, filter, "parquet_reader_set_filter", row_group_lo, row_group_hi, & row_lo, row_hi) end subroutine parquet_reader_set_filter_impl module procedure parquet_reader_set_sort character(len=:), allocatable :: name_suffix call check_reader_open(reader, "parquet_reader_set_sort") if (parquet_reader_has_sort(reader%handle) /= 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_reader_set_sort: this reader already has an active sort; add every key to " // & "one parquet_sortkey instead" // name_suffix end if if (parquet_reader_has_decoded_columns(reader%handle) /= 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_reader_set_sort: a column has already been read on this reader; a sort " // & "must be applied before any column is read" // name_suffix end if ! A CHUNKED read caches nothing, so the guard above cannot see it -- see ! parquet_reader_has_chunk_reads. Rows already handed back are in physical row-group order ! and could not be reconciled with anything read after the permutation is installed. if (parquet_reader_has_chunk_reads(reader%handle) /= 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_reader_set_sort: a chunked read has already been done on this reader; a " // & "sort must be applied before any column is read" // name_suffix end if if (sort_by%n == 0) return call parquet_apply_sort(reader, sort_by, "parquet_reader_set_sort") end procedure parquet_reader_set_sort module procedure parquet_reader_adopt_transform character(len=:), allocatable :: name_suffix character(len=1024) :: c_err integer(c_long_long) :: status ! call check_reader_open(reader, "parquet_reader_adopt_transform") call check_reader_open(source, "parquet_reader_adopt_transform") c_err = "" ! Every precondition is checked on the C++ side rather than duplicated here: they are all ! about state this module cannot see (whether a mask is installed, whether a column has been ! decoded, whether the source's deferred sample draw has completed), and a Fortran-side copy ! of any of them could only go stale. status = c_reader_adopt_transform(reader%handle, source%handle, c_err, int(len(c_err), kind=c_long_long)) if (status /= 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_reader_adopt_transform: " // trim(c_err) // name_suffix end if end procedure parquet_reader_adopt_transform module procedure parquet_open_reader_nrows_int64 call parquet_open_reader_base(reader, filename, use_threads, filter, sample_fraction, sample_seed, schema, qc, & qc_soft, prefetch, sort_by) call parquet_get_nrows(reader, nrows, check_positive=.true.) end procedure parquet_open_reader_nrows_int64 module procedure parquet_open_reader_nrows_int32 call parquet_open_reader_base(reader, filename, use_threads, filter, sample_fraction, sample_seed, schema, qc, & qc_soft, prefetch, sort_by) call parquet_get_nrows(reader, nrows, check_positive=.true.) end procedure parquet_open_reader_nrows_int32 module procedure parquet_close_reader logical :: do_check_complete, do_check_hard if (.not. c_associated(reader%handle)) then error stop "parquet_close_reader: reader has not been opened, or was already closed" end if if (present(print_stat)) then if (print_stat) call parquet_reader_print_stat(reader%handle) end if do_check_complete = .false. if (present(check_complete)) do_check_complete = check_complete if (do_check_complete) then do_check_hard = .true. if (present(check_hard)) do_check_hard = check_hard call parquet_reader_check_complete(reader%handle, merge(1_c_int, 0_c_int, do_check_hard)) end if call close_parquet_reader(reader%handle) reader%handle = c_null_ptr end procedure parquet_close_reader !> Warms the cache for every column in `names` with a single, parallelizable !> (use_threads is on -- see create_parquet_reader) Arrow read, instead of !> one lazy single-column read per name. Purely additive: parquet_read_column !> (or any other read call) still works for any column, prefetched or not -- !> a non-prefetched name simply falls through to the existing lazy, !> read-on-first-request path exactly as if this was never called. module procedure parquet_prefetch_columns_array character(kind=c_char), allocatable :: packed(:) integer :: i, j, k, item_len, n character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). call check_reader_open(reader, "parquet_prefetch_columns") n = size(names) if (n <= 0) return do i = 1, n if (parquet_reader_has_column(reader%handle, trim(names(i))//char(0)) == 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_prefetch_columns: column not found in parquet file: " // trim(names(i)) // & name_suffix end if end do item_len = len(names(1)) allocate(packed(item_len * n)) k = 0 do i = 1, n do j = 1, item_len k = k + 1 packed(k) = achar(iachar(names(i)(j:j)), kind=c_char) end do end do call parquet_reader_prefetch_columns(reader%handle, packed, int(item_len, kind=c_long_long), int(n, kind=c_long_long)) end procedure parquet_prefetch_columns_array !> Scalar-string form of parquet_prefetch_columns: splits `names` on commas !> and/or semicolons ("ra;dec, mag"), trims each token, drops empty tokens !> (so trailing/repeated delimiters are harmless), packs them into a !> uniform-length array, and delegates to the array form -- which does the !> per-name existence check and the actual prefetch. Building the array !> here from the split tokens sizes its length to the longest name, so it !> cannot silently truncate a name the way a hand-declared fixed-length !> array can. module procedure parquet_prefetch_columns_string character(len=:), allocatable :: name_arr(:) ! The tokenizer lives in parquet_core (parquet_split_name_list) rather than here, ! because parquet_tables' own name- and key-list forms split identically and two ! copies of one punctuation convention is exactly the drift that would let ! t%prefetch("a,,b") and parquet_prefetch_columns(rdr, "a,,b") disagree. ! ! The array form still runs even for zero tokens: it does the reader-open and ! column-existence checks, which an empty list must not skip. call parquet_split_name_list(names, name_arr) call parquet_prefetch_columns_array(reader, name_arr) end procedure parquet_prefetch_columns_string !> Safety net for a reader whose handle is still open when it goes out of !> scope or is overwritten -- frees the underlying C++ object so the !> process doesn't leak it. Always prefer calling parquet_close_reader !> explicitly. !> Shared by both parquet_get_nrows overloads' check_positive=.true. path. !> nrows64 is always <= 0 here (never negative in practice, but the check !> itself is written against <= 0 to also cover that impossible case). !> If a filter narrowed the row count, parquet_reader_get_total_nrows !> differs from nrows64 (the post-filter count), so that case names the !> unfiltered total too, rather than just repeating "zero" twice. subroutine check_nrows_positive(reader, nrows64) type(parquet_reader), intent(in) :: reader !! open reader; named in the error-stop message. integer(int64), intent(in) :: nrows64 !! post-filter row count; error stops here since it is <= 0. integer(int64) :: total_nrows character(len=32) :: buf if (nrows64 > 0) return total_nrows = int(parquet_reader_get_total_nrows(reader%handle), kind=int64) if (total_nrows /= nrows64) then write(buf, '(I0)') total_nrows error stop "parquet_get_nrows: file " // trim(reader%filename) // & " has zero rows after filtering (" // trim(buf) // " total)" else error stop "parquet_get_nrows: file " // trim(reader%filename) // " has zero rows" ! GCOVR_EXCL_LINE end if end subroutine check_nrows_positive module procedure parquet_get_nrows_int64 call check_reader_open(reader, "parquet_get_nrows") nrows = int(parquet_reader_get_nrows(reader%handle), kind=int64) if (present(check_positive)) then if (check_positive) call check_nrows_positive(reader, nrows) end if end procedure parquet_get_nrows_int64 module procedure parquet_get_nrows_int32 integer(int64) :: nrows64 character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). call check_reader_open(reader, "parquet_get_nrows") nrows64 = int(parquet_reader_get_nrows(reader%handle), kind=int64) if (present(check_positive)) then if (check_positive) call check_nrows_positive(reader, nrows64) end if if (nrows64 > huge(0_int32)) then ! GCOVR_EXCL_START -- gcov attribution artifact call reader_filename_suffix(reader, name_suffix) error stop "parquet_get_nrows: number of rows exceeds int32 range" // name_suffix end if ! GCOVR_EXCL_STOP nrows = int(nrows64, kind=int32) end procedure parquet_get_nrows_int32 module procedure parquet_get_num_row_groups_int64 call check_reader_open(reader, "parquet_get_num_row_groups") num_row_groups = int(parquet_reader_get_num_row_groups(reader%handle), kind=int64) end procedure parquet_get_num_row_groups_int64 module procedure parquet_get_num_row_groups_int32 integer(int64) :: num_row_groups64 character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). call check_reader_open(reader, "parquet_get_num_row_groups") num_row_groups64 = int(parquet_reader_get_num_row_groups(reader%handle), kind=int64) if (num_row_groups64 > huge(0_int32)) then ! GCOVR_EXCL_START -- gcov attribution artifact call reader_filename_suffix(reader, name_suffix) error stop "parquet_get_num_row_groups: number of row groups exceeds int32 range" // & name_suffix end if ! GCOVR_EXCL_STOP num_row_groups = int(num_row_groups64, kind=int32) end procedure parquet_get_num_row_groups_int32 !> Shared body of parquet_get_chunk_size_reader_int32/_int64 -- see the generic interface's !> own doc comment in parquet_core.f90. `row_group` is 0 if the caller omitted its own optional !> row_group argument (both kind-specifics translate "absent" to 0 before calling in), !> resolved here to 1 (the first row group) -- 0 can never be a valid 1-based row_group, so !> it is unambiguous as an "absent" sentinel. !> Shared worker behind parquet_column_has_nulls's two kind specifics. function parquet_column_has_nulls_impl(reader, name, row_group_lo, row_group_hi) result(has_nulls) type(parquet_reader), intent(in) :: reader !! open reader. character(len=*), intent(in) :: name !! column name. integer(int64), intent(in) :: row_group_lo !! first row group (1-based); <= 0 means all. integer(int64), intent(in) :: row_group_hi !! last row group (1-based, inclusive). logical :: has_nulls !! .true. if it has Nulls, or if the file cannot say. ! call check_reader_open(reader, "parquet_column_has_nulls") call check_column_exists(reader, name, "parquet_column_has_nulls") has_nulls = parquet_reader_column_has_nulls(reader%handle, trim(name)//char(0), & row_group_lo, row_group_hi) /= 0 end function parquet_column_has_nulls_impl ! !> Shared worker behind parquet_measure_list_width's two kind specifics. subroutine parquet_measure_list_width_impl(reader, name, row_group_lo, row_group_hi, proven, width) type(parquet_reader), intent(in) :: reader !! open reader. character(len=*), intent(in) :: name !! column name. integer(int64), intent(in) :: row_group_lo !! first row group (1-based); <= 0 means all. integer(int64), intent(in) :: row_group_hi !! last row group (1-based, inclusive). logical, intent(in) :: proven !! .true.: prove by reading; .false.: footer only. integer, intent(out) :: width !! uniform element count per row, or 1. integer(int64) :: w ! call check_reader_open(reader, "parquet_measure_list_width") call check_column_exists(reader, name, "parquet_measure_list_width") if (proven) then w = parquet_reader_list_width_verified(reader%handle, trim(name)//char(0), row_group_lo, row_group_hi) else w = parquet_reader_list_width_candidate(reader%handle, trim(name)//char(0), row_group_lo, row_group_hi) end if ! Deliberately NOT clamped to 1: a column with no rows reports 0, matching what ! parquet_get_col_size has always answered for an empty list column. width = int(w) end subroutine parquet_measure_list_width_impl ! subroutine parquet_get_chunk_size_reader_impl(reader, chunk_size, row_group) type(parquet_reader), intent(in) :: reader !! open reader. integer(int64), intent(out) :: chunk_size !! row group's own physical row count. integer(int64), intent(in) :: row_group !! 1-based row group, or 0 for "use the first row group". integer(int64) :: rg, num_row_groups character(len=32) :: rg_str, total_str character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). call check_reader_open(reader, "parquet_get_chunk_size") ! A row group's row count is only meaningful while row groups still correspond to ! contiguous result rows, which a sort permutation ends -- see check_reader_no_sort. call check_reader_no_sort(reader, "parquet_get_chunk_size") rg = merge(row_group, 1_int64, row_group > 0) num_row_groups = int(parquet_reader_get_num_row_groups(reader%handle), kind=int64) if (rg < 1 .or. rg > num_row_groups) then write(rg_str, '(i0)') rg write(total_str, '(i0)') num_row_groups call reader_filename_suffix(reader, name_suffix) error stop "parquet_get_chunk_size: row_group " // trim(rg_str) // " out of range (file has " // & trim(total_str) // " row group(s))" // name_suffix end if chunk_size = int(parquet_reader_get_chunk_size_at(reader%handle, rg), kind=int64) end subroutine parquet_get_chunk_size_reader_impl module procedure parquet_get_chunk_size_reader_int32 integer(int64) :: chunk_size64, row_group64 row_group64 = 0_int64 if (present(row_group)) row_group64 = int(row_group, kind=int64) call parquet_get_chunk_size_reader_impl(reader, chunk_size64, row_group64) chunk_size = int(chunk_size64, kind=int32) end procedure parquet_get_chunk_size_reader_int32 module procedure parquet_get_chunk_size_reader_int64 integer(int64) :: row_group64 row_group64 = 0_int64 if (present(row_group)) row_group64 = row_group call parquet_get_chunk_size_reader_impl(reader, chunk_size, row_group64) end procedure parquet_get_chunk_size_reader_int64 module procedure parquet_get_col_size call check_reader_open(reader, "parquet_get_col_size") call check_column_exists(reader, name, "parquet_get_col_size") col_size = int(parquet_reader_get_column_col_size(reader%handle, trim(name)//char(0))) end procedure parquet_get_col_size module procedure parquet_measure_list_width_int32 call parquet_measure_list_width_impl(reader, name, int(row_group_lo, int64), & int(row_group_hi, int64), proven, width) end procedure parquet_measure_list_width_int32 module procedure parquet_measure_list_width_int64 call parquet_measure_list_width_impl(reader, name, row_group_lo, row_group_hi, proven, width) end procedure parquet_measure_list_width_int64 module procedure parquet_column_has_nulls_int32 has_nulls = parquet_column_has_nulls_impl(reader, name, int(row_group_lo, int64), int(row_group_hi, int64)) end procedure parquet_column_has_nulls_int32 module procedure parquet_column_has_nulls_int64 has_nulls = parquet_column_has_nulls_impl(reader, name, row_group_lo, row_group_hi) end procedure parquet_column_has_nulls_int64 module procedure parquet_column_width_needs_data call check_reader_open(reader, "parquet_column_width_needs_data") call check_column_exists(reader, name, "parquet_column_width_needs_data") needs_data = parquet_reader_column_width_is_deferred(reader%handle, trim(name)//char(0)) /= 0 end procedure parquet_column_width_needs_data module procedure parquet_get_column_total_elements_int64 call check_reader_open(reader, "parquet_get_column_total_elements") call check_column_exists(reader, name, "parquet_get_column_total_elements") total_elements = int(parquet_reader_get_column_total_elements(reader%handle, trim(name)//char(0)), kind=int64) end procedure parquet_get_column_total_elements_int64 module procedure parquet_get_column_total_elements_int32 integer(int64) :: nelem64 character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). call check_reader_open(reader, "parquet_get_column_total_elements") call check_column_exists(reader, name, "parquet_get_column_total_elements") nelem64 = int(parquet_reader_get_column_total_elements(reader%handle, trim(name)//char(0)), kind=int64) if (nelem64 > huge(0_int32)) then ! GCOVR_EXCL_START -- gcov attribution artifact call reader_filename_suffix(reader, name_suffix) error stop "parquet_get_column_total_elements: number of elements exceeds int32 range for column: " // & trim(name) // name_suffix end if ! GCOVR_EXCL_STOP total_elements = int(nelem64, kind=int32) end procedure parquet_get_column_total_elements_int32 module procedure parquet_get_string_length call check_reader_open(reader, "parquet_get_string_length") call check_column_exists(reader, name, "parquet_get_string_length") max_string_length = int(parquet_reader_get_string_length(reader%handle, trim(name)//char(0))) end procedure parquet_get_string_length !> Every parquet_read_column variant calls this with its own `values` !> array's row count (size(values) for a scalar column, size(values, 2) !> for a vector column) before reading any data. A mismatch against the !> file's actual row count fails immediately with error stop, instead of !> reaching the underlying C++ read call, whose own "nrows mismatch" !> check reports a clean diagnostic but aborts the process rather than !> returning control to Fortran (see report_fatal_error in !> parquet_wrapper.cpp). !> Plain contained subroutine (not a module procedure): its body already !> lived here in the parquet_read parent, not a descendant submodule, so !> keeping it a module procedure after relocating the interface into this !> same file's own spec would mean parquet_read implementing its own !> spec-declared interface -- not the ancestor/descendant relationship !> module procedures require (same reasoning as parquet_metadata's !> parquet_parse_col_map, see Phase 2). Descendants reach it by host !> association (fact 3.6). subroutine parquet_check_read_row_count(reader, name, given_nrows) type(parquet_reader), intent(in) :: reader !! open reader. character(len=*), intent(in) :: name !! column being read; named only in the error-stop message. integer(c_long_long), intent(in) :: given_nrows !! row count of this read call's own `values` array. integer(c_long_long) :: file_nrows character(len=32) :: expected_str, got_str character(len=:), allocatable :: name_suffix !! scratch (reader_filename_suffix). file_nrows = parquet_reader_get_nrows(reader%handle) if (given_nrows /= file_nrows) then write(expected_str, '(i0)') file_nrows write(got_str, '(i0)') given_nrows call reader_filename_suffix(reader, name_suffix) error stop "parquet_read_column: row count mismatch for column " // trim(name) // & ": file has " // trim(expected_str) // " rows but the values array implies " // trim(got_str) // & name_suffix end if end subroutine parquet_check_read_row_count module procedure parquet_get_column_time_info integer(c_long_long) :: tzlen call check_reader_open(reader, "parquet_get_column_time_info") call check_column_exists(reader, name, "parquet_get_column_time_info") if (present(unit)) then unit = int(parquet_reader_get_column_time_unit(reader%handle, trim(name)//char(0))) end if if (present(timezone)) then tzlen = parquet_reader_get_column_timezone_length(reader%handle, trim(name)//char(0)) allocate(character(len=int(tzlen)) :: timezone) if (tzlen > 0) call parquet_reader_get_column_timezone(reader%handle, trim(name)//char(0), timezone, tzlen) end if end procedure parquet_get_column_time_info !> Resolves `name`'s canonical physical data type (see valid_query_data_types in parquet_core.f90), !> without checking that `name` exists first -- every caller (parquet_column_exists/ !> parquet_get_column_type) already validated existence via parquet_reader_has_column/ !> check_column_exists beforehand. `recognized` is .false. if the physical type falls outside !> the nine canonical tokens, in which case `type_name` instead holds a raw Arrow type !> description for a diagnostic message. subroutine resolve_column_type(reader, name, type_name, recognized) type(parquet_reader), intent(in) :: reader !! open reader whose column is queried. character(len=*), intent(in) :: name !! existing column name. character(len=:), allocatable, intent(out) :: type_name !! canonical token, or raw type description. logical, intent(out) :: recognized !! .true. if type_name is one of the nine canonical tokens. character(len=32) :: buf recognized = parquet_reader_get_column_type_name(reader%handle, trim(name)//char(0), buf, & int(len(buf), kind=c_long_long)) /= 0 type_name = trim(buf) end subroutine resolve_column_type !> Splits `types` (known non-blank) on commas into trimmed, lowercased tokens. subroutine split_type_filter_tokens(types, tokens) character(len=*), intent(in) :: types !! comma-separated filter string. character(len=9), allocatable, intent(out) :: tokens(:) !! trimmed, lowercased tokens. integer :: filter_len, start_pos, comma_pos, comma_count, i character(len=:), allocatable :: token, token_lower filter_len = len_trim(types) comma_count = 0 do i = 1, filter_len if (types(i:i) == ",") comma_count = comma_count + 1 end do allocate(tokens(comma_count + 1)) start_pos = 1 i = 0 do i = i + 1 comma_pos = index(types(start_pos:filter_len), ",") if (comma_pos == 0) then token = types(start_pos:filter_len) else token = types(start_pos:start_pos + comma_pos - 2) end if call parquet_to_lower(trim(adjustl(token)), token_lower) tokens(i) = token_lower if (comma_pos == 0) exit start_pos = start_pos + comma_pos end do end subroutine split_type_filter_tokens !> .true. if `token` (already trimmed/lowercased) is one of valid_query_data_types's nine !> single tokens, or one of the group aliases "int"/"float"/"temporal". pure function type_filter_token_valid(token) result(valid) character(len=*), intent(in) :: token !! candidate token, already trimmed and lowercased. logical :: valid !! .true. if recognized. integer :: j valid = trim(token) == "int" .or. trim(token) == "float" .or. trim(token) == "temporal" if (valid) return do j = 1, size(valid_query_data_types) if (trim(token) == trim(valid_query_data_types(j))) then valid = .true. return end if end do end function type_filter_token_valid !> .true. if `resolved_type` (one of valid_query_data_types's nine tokens) satisfies `token`, !> a single already-validated filter token (a canonical type name or a group alias). pure function type_filter_token_matches(token, resolved_type) result(matches) character(len=*), intent(in) :: token !! single filter token, already validated. character(len=*), intent(in) :: resolved_type !! column's resolved canonical type. logical :: matches !! .true. if resolved_type satisfies token. select case (trim(token)) case ("int") matches = trim(resolved_type) == "int32" .or. trim(resolved_type) == "int64" case ("float") ! Every numeric column, integers included -- an integer column IS readable into a ! float array, which is the question this alias asks. That makes an alias deliberately ! NOT the union of its member tokens: `float` matches an int32 column while `float64` ! does not, because the two ask different things ("can I read this as a float at all?" ! against "is float64 the right declaration?"). Both are useful; see this procedure's ! own doc-comment in parquet_core.f90. matches = trim(resolved_type) == "float32" .or. trim(resolved_type) == "float64" .or. & trim(resolved_type) == "int32" .or. trim(resolved_type) == "int64" case ("temporal") matches = trim(resolved_type) == "date" .or. trim(resolved_type) == "time" .or. & trim(resolved_type) == "timestamp" case default matches = trim(token) == trim(resolved_type) end select end function type_filter_token_matches module procedure parquet_column_exists character(len=9), allocatable :: tokens(:) character(len=:), allocatable :: resolved_type, name_suffix logical :: recognized integer :: i call check_reader_open(reader, "parquet_column_exists") if (present(types)) then if (len_trim(types) == 0) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_column_exists: types= must not be empty" // name_suffix end if call split_type_filter_tokens(types, tokens) do i = 1, size(tokens) if (.not. type_filter_token_valid(tokens(i))) then call reader_filename_suffix(reader, name_suffix) error stop "parquet_column_exists: unrecognized data type token '" // trim(tokens(i)) // & "' in types= (valid: int32, int64, float32, float64, boolean, string, date, time, " // & "timestamp, or the group aliases int/float/temporal)" // name_suffix end if end do end if exists = parquet_reader_has_column(reader%handle, trim(name)//char(0)) /= 0 if (.not. exists .or. .not. present(types)) return call resolve_column_type(reader, name, resolved_type, recognized) exists = .false. if (.not. recognized) return do i = 1, size(tokens) if (type_filter_token_matches(tokens(i), resolved_type)) then exists = .true. return end if end do end procedure parquet_column_exists module procedure parquet_get_column_type logical :: recognized call check_reader_open(reader, "parquet_get_column_type") ! Still aborts on a name that does not exist -- that is a caller mistake, and ! parquet_column_exists is the query for "is it there?". An unreadable TYPE is not a ! mistake, though: answering "unknown" is the whole point of asking, so `recognized` is ! deliberately unused beyond documenting that resolve_column_type already wrote the token. call check_column_exists(reader, name, "parquet_get_column_type") call resolve_column_type(reader, name, type_name, recognized) end procedure parquet_get_column_type module procedure parquet_get_column_nullable call check_reader_open(reader, "parquet_get_column_nullable") ! Same rule as parquet_get_column_type: a missing name is a caller mistake and aborts, ! while a column whose TYPE this library cannot read still has a meaningful flag and ! gets an answer. call check_column_exists(reader, name, "parquet_get_column_nullable") is_nullable = parquet_reader_get_column_nullable(reader%handle, trim(name)//char(0)) /= 0_c_long_long end procedure parquet_get_column_nullable module procedure parquet_get_column_names integer(c_int32_t) :: ncols, i integer(c_long_long) :: name_len, max_len character(len=:), allocatable :: buf call check_reader_open(reader, "parquet_get_column_names") ncols = parquet_reader_get_column_count(reader%handle) ! Two passes: the first finds the longest name so `names` can be allocated at exactly ! that length (a fixed-length array cannot hold ragged names, and guessing a maximum ! would silently truncate a long dotted struct path). Both passes are pure schema ! lookups into a cache built at open time -- neither reads any column data. max_len = 0 do i = 0, ncols - 1 name_len = parquet_reader_get_column_name_length(reader%handle, i) if (name_len > max_len) max_len = name_len end do allocate(character(len=int(max_len)) :: names(ncols)) if (ncols == 0) return allocate(character(len=int(max_len)) :: buf) do i = 0, ncols - 1 call parquet_reader_get_column_name(reader%handle, i, buf, max_len) names(i + 1) = buf end do end procedure parquet_get_column_names ! module procedure parquet_get_physical_row_indices integer(c_long_long) :: n ! call check_reader_open(reader, "parquet_get_physical_row_indices") n = parquet_reader_get_nrows(reader%handle) allocate(rows(n)) if (n == 0) return call parquet_reader_physical_row_indices(reader%handle, rows, n) end procedure parquet_get_physical_row_indices ! module procedure parquet_get_metadata_items integer :: n, i, klen, vlen ! call check_reader_open(reader, "parquet_get_metadata_items") ! The reader already holds the whole key/value table in memory (populate_reader_metadata ! copies it at open), so this is a reshape of what is there, not a read. n = 0 if (allocated(reader%metadata%items)) n = size(reader%metadata%items) ! Same two-pass shape as parquet_get_column_names: one length for the whole array, found ! first, because a fixed-length array cannot hold ragged entries and a guessed maximum ! would truncate silently. Keys and values are sized independently -- a long value should ! not widen every key. klen = 0 vlen = 0 do i = 1, n klen = max(klen, len(reader%metadata%items(i)%key)) vlen = max(vlen, len(reader%metadata%items(i)%value)) end do allocate(character(len=klen) :: keys(n)) allocate(character(len=vlen) :: values(n)) do i = 1, n keys(i) = reader%metadata%items(i)%key values(i) = reader%metadata%items(i)%value end do end procedure parquet_get_metadata_items module procedure parquet_release_column call check_reader_open(reader, "parquet_release_column") call parquet_reader_release_column(reader%handle, trim(name)//char(0)) end procedure parquet_release_column end submodule parquet_read