!=========================================== ! Author: Elmo Tempel (elmo.tempel@ut.ee) !=========================================== ! !> `parquet_table_col` — a resolved handle on one column of a `parquet_table`. !! !! The column-major twin of `parquet_tables_row.f90`: where a `parquet_table_row` fixes the ROW and !! leaves the column to be named per access, this fixes the COLUMN and leaves the row to be given !! per access. A per-element loop over one column is the shape that pays, because the name lookup !! it removes is 52–77% of what a `%get_element` costs. !! !! Three rules run through the file: !! !! * **Resolve once, at creation.** `column_by_name`/`column_by_index` do the whole of !! `table_resolve`'s work — the lookup, the unsupported-type refusal, the lazy first touch — and !! store the slot, the kind and the row scope. Nothing here repeats any of it. !! * **A handle does not survive a structural change.** Every access runs `col_resolve`, which is !! an `associated` test and one `integer(int64)` comparison against the stamp taken at creation. !! That is deliberately conservative — `%append` does not move existing slots, so a handle !! *could* survive one — because a single total rule beats a list of exceptions the next !! mutation would silently fall outside. !! * **The handle points at `cache`, never at the table.** Same rule as the row handle, and it is !! what lets a caller hold a handle without the table needing the `target` attribute. submodule (parquet_tables) parquet_tables_col implicit none ! contains ! !> Fills a handle from a slot the caller has already resolved. The one place a handle is !! constructed, so the stamp and the scope cannot be taken from different moments. subroutine col_attach(c, cache, idx, scope) type(parquet_table_col), intent(out) :: c !! the handle to fill. type(parquet_table_cache), pointer, intent(in) :: cache !! the table's column store. integer, intent(in) :: idx !! validated slot index. type(table_scope), intent(in) :: scope !! the table's row scope, copied by value. ! c%cache => cache c%slot = idx c%colkind = cache%cols(idx)%declared_kind c%gen = cache%generation c%scope = scope end subroutine col_attach ! module procedure table_resolve_to_handle integer :: idx ! ! The handle is `intent(out)`, so a reported miss leaves it at its default initializers -- ! detached, generation -1 -- and %is_valid() answers .false. without anything else to do. call table_resolve(self, name, proc, idx, found) if (idx == 0) return call col_attach(c, self%cache, idx, table_scope_of(self)) end procedure table_resolve_to_handle ! module procedure column_by_name call table_resolve_to_handle(self, name, "column", c, found) end procedure column_by_name ! module procedure column_by_index integer :: idx ! call table_check_open(self, "column") call table_check_no_append(self%cache, "column") call table_slot_or_fail(self, j, "column", idx, found) if (idx == 0) return ! The same tail every name-resolved value access runs, so a handle made by position is ! subject to exactly the unsupported-type and residency rules one made by name is. call table_resolve_slot(self, idx, "column", found) if (idx == 0) return call col_attach(c, self%cache, idx, table_scope_of(self)) end procedure column_by_index ! module procedure col_is_valid ok = .false. if (.not. associated(self%cache)) return ok = self%cache%generation == self%gen end procedure col_is_valid ! module procedure col_index call col_resolve(self, "index") j = self%slot end procedure col_index ! module procedure col_kind call col_resolve(self, "kind") k = self%colkind end procedure col_kind ! ! The four descriptor queries below read the slot directly, with no `table_resolve_width` call ! of the sort `slot_kind`/`slot_width` make on the table's own forms. That is not a shortcut: ! a handle can only exist once `table_resolve_slot` has run, which is where a plain LIST ! column's width is proven and its kind settled — so by the time there is a handle to ask, ! the descriptor is already the resolved answer rather than the open-time guess. Anything that ! could change it is structural and invalidates the handle first. ! module procedure col_name call col_resolve(self, "name") nm = self%cache%cols(self%slot)%name end procedure col_name ! module procedure col_width call col_resolve(self, "width") wdt = self%cache%cols(self%slot)%width end procedure col_width ! module procedure col_unit call col_resolve(self, "unit") ! The descriptor first, because it answers for a column nothing has read yet — a ! file-backed column's unit comes from the read-in MAML at open, not from its values. ! Same order as the table's own %unit, which is where this rule is explained in full. if (allocated(self%cache%cols(self%slot)%unit)) then u = self%cache%cols(self%slot)%unit return end if call self%cache%cols(self%slot)%values%unit_string(u) end procedure col_unit ! module procedure col_residency call col_resolve(self, "residency") r = self%cache%cols(self%slot)%residency end procedure col_residency ! module procedure col_set_user_populated character(len=:), allocatable :: sfx ! call col_resolve(self, "set_user_populated") ! The same rule the table form applies, and for the same reason -- see ! table_set_user_populated, which is where it is explained. Kept as its own body rather ! than forwarding to the table form because that one is name-keyed: the handle would have ! to hand back the name it already resolved past, only for the table to look it up again. ! Defensive: no caller can reach this through a handle. Making a handle RESOLVES the ! column, so it is resident by then; every route that later takes its values away ! (%evict_column, %reload) bumps the table's generation, which col_resolve above rejects ! first; and %column declines to attach a handle to an unsupported column at all. Kept ! because the rule is the table form's too, and a future non-generation-bumping way to ! release a column's values would land here. Verified by trying all three routes. ! gcov attribution artifact: the condition is evaluated on every call, so the `if` line ! registers hits while its body reliably shows zero. if (flag .and. self%cache%cols(self%slot)%residency /= RES_FULL) then ! GCOVR_EXCL_START call table_context_suffix(self%cache, self%cache%cols(self%slot)%name, sfx) error stop EP // "column handle: set_user_populated: this column holds no values " // & "to claim -- it has not been read, or was evicted; read it first " // & "(%prefetch/%get), or pass .false." // sfx end if ! GCOVR_EXCL_STOP self%cache%cols(self%slot)%user_populated = flag end procedure col_set_user_populated ! module procedure col_is_user_populated call col_resolve(self, "is_user_populated") ok = self%cache%cols(self%slot)%user_populated end procedure col_is_user_populated ! module procedure col_resolve character(len=:), allocatable :: sfx character(len=32) :: g_now, g_then ! if (.not. associated(self%cache)) then error stop EP // "column handle: " // trim(proc) // ": this handle is not attached " // & "to a table" end if if (self%cache%generation == self%gen) return ! The remedy is named because the cause is usually several statements away: a %drop_column, ! a %sort_by or an %append somewhere between making the handle and using it. write(g_now, "(I0)") self%cache%generation write(g_then, "(I0)") self%gen call table_context_suffix(self%cache, self%cache%cols(self%slot)%name, sfx) error stop EP // "column handle: " // trim(proc) // ": this table has changed " // & "structurally since the handle was made (generation " // trim(g_now) // & ", handle " // trim(g_then) // "); re-fetch it with %column(...)" // sfx end procedure col_resolve ! module procedure col_require_row character(len=32) :: got, want ! if (i >= 1_int64 .and. i <= self%scope%nrows) return write(got, "(I0)") i write(want, "(I0)") self%scope%nrows error stop EP // "column handle: " // trim(proc) // ": row index " // trim(got) // & " is outside this table's 1.." // trim(want) // " rows" end procedure col_require_row ! ! !> Currently has NO caller: every typed `%get`/`%set` on a handle reaches the table's own kind !! check first, which reports the same mismatch in the table's wording. Kept rather than !! deleted because it is the handle's own message -- it names the handle, which is what a !! caller who resolved a column once and reads it a million times needs to see -- and because !! a future per-kind body that checks before delegating would use it. Excluded from coverage !! because it is private to parquet_tables, so nothing can reach it while it has no caller. ! GCOVR_EXCL_START module procedure col_kind_error character(len=:), allocatable :: sfx, got, wanted ! ! `parquet_kind_name` is a SUBROUTINE with an allocatable-character out-argument, not a ! function -- this project bans character-returning functions outright (a confirmed ! gfortran thread-safety bug). See CLAUDE.md. call parquet_kind_name(self%colkind, got) call parquet_kind_name(want, wanted) call table_context_suffix(self%cache, self%cache%cols(self%slot)%name, sfx) error stop EP // "column handle: " // trim(proc) // ": this column holds " // & trim(got) // ", which cannot be read as " // trim(wanted) // sfx end procedure col_kind_error ! GCOVR_EXCL_STOP ! ! ---- the null trio ------------------------------------------------------------------------ ! ! These are the one part of the handle's surface that is NOT per-kind: they take no value ! argument, so one body serves every column type. That is what let them cover all 18 kinds from ! the start, while %get/%set were still being added a few kinds at a time -- a handle whose ! %is_null answered only for some kinds would be a runtime surprise decided by the file's ! schema, where a missing %get is a compile-time error the caller cannot ship past. ! module procedure col_is_null_i32 isnull = self%is_null(int(i, int64)) end procedure col_is_null_i32 ! module procedure col_is_null_i64 call col_resolve(self, "is_null") call col_require_row(self, i, "is_null") isnull = parquet_column_is_null(self%cache%cols(self%slot)%values, i) end procedure col_is_null_i64 ! module procedure col_is_null_e32 isnull = self%is_null(int(i, int64), int(e, int64)) end procedure col_is_null_e32 ! module procedure col_is_null_e64 call col_resolve(self, "is_null") call col_require_row(self, i, "is_null") ! The element index is bounds-checked by parquet_column itself, which names the element ! axis in its message -- the mistake this guards is passing a FLAT element position. isnull = parquet_column_is_null(self%cache%cols(self%slot)%values, i, e) end procedure col_is_null_e64 ! module procedure col_set_null_i32 call self%set_null(int(i, int64)) end procedure col_set_null_i32 ! module procedure col_set_null_i64 call col_resolve(self, "set_null") call col_require_row(self, i, "set_null") ! nulling=.true.: the FIRST null on a column allocates its validity storage, and two ! threads doing that race with no diagnostic. Same rule the table's own %set_null obeys. call cache_check_shared_write(self%cache, self%slot, "set_null", nulling=.true.) call parquet_column_set_null(self%cache%cols(self%slot)%values, i) end procedure col_set_null_i64 ! module procedure col_set_null_e32 call self%set_null(int(i, int64), int(e, int64)) end procedure col_set_null_e32 ! module procedure col_set_null_e64 call col_resolve(self, "set_null") call col_require_row(self, i, "set_null") call cache_check_shared_write(self%cache, self%slot, "set_null", nulling=.true.) call parquet_column_set_null(self%cache%cols(self%slot)%values, i, e) end procedure col_set_null_e64 ! module procedure col_clear_null_i32 call self%clear_null(int(i, int64)) end procedure col_clear_null_i32 ! module procedure col_clear_null_i64 call col_resolve(self, "clear_null") call col_require_row(self, i, "clear_null") ! nulling=.false.: clearing a null cannot be the write that first allocates validity ! storage, because there is nothing to clear until something allocated it. call cache_check_shared_write(self%cache, self%slot, "clear_null", nulling=.false.) call parquet_column_clear_null(self%cache%cols(self%slot)%values, i) end procedure col_clear_null_i64 ! module procedure col_clear_null_e32 call self%clear_null(int(i, int64), int(e, int64)) end procedure col_clear_null_e32 ! module procedure col_clear_null_e64 call col_resolve(self, "clear_null") call col_require_row(self, i, "clear_null") call cache_check_shared_write(self%cache, self%slot, "clear_null", nulling=.false.) call parquet_column_clear_null(self%cache%cols(self%slot)%values, i, e) end procedure col_clear_null_e64 ! end submodule parquet_tables_col ! GCOVR_EXCL_LINE