parquet_tables_col.f90 Source File


Source Code

!===========================================
! 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