Skip to content

Commit 9b8a75f

Browse files
committed
feat: Materialize ucs4/ascii array data as string views
1 parent 0e50495 commit 9b8a75f

3 files changed

Lines changed: 240 additions & 12 deletions

File tree

src/ASDF.jl

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -492,10 +492,12 @@ An ASDF fixed-length ASCII string datatype, corresponding to the `["ascii", N]`
492492
the ASDF datatype spec. Each element occupies exactly `length` bytes, one byte per
493493
character (all codepoints < 128).
494494
495-
The corresponding Julia type is `NTuple{N, UInt8}`. Byte order is irrelevant for this
496-
type since each character is a single byte.
495+
The corresponding `isbitstype` Julia type is `NTuple{N, UInt8}`. Byte order is irrelevant for
496+
this type since each character is a single byte. When an array of this datatype is materialized
497+
with [`Base.getindex(ndarray::NDArray)`](@ref), each element is presented as an
498+
[`AsciiString`](@ref): a thin `AbstractString` view over those bytes, with identical contents.
497499
498-
See also: [`Ucs4Datatype`](@ref), [`parse_asdf_datatype`](@ref).
500+
See also: [`Ucs4Datatype`](@ref), [`AsciiString`](@ref), [`parse_asdf_datatype`](@ref).
499501
"""
500502
struct AsciiDatatype
501503
length::Int # Bytes (1 per char)
@@ -508,9 +510,12 @@ An ASDF fixed-length UCS-4 string datatype, corresponding to the `["ucs4", N]` f
508510
the ASDF datatype spec. Each element occupies exactly `4 * length` bytes, with each
509511
character encoded as a 4-byte UInt32 in the array's declared byte order.
510512
511-
The corresponding Julia type is `NTuple{N, UInt32}`.
513+
The corresponding `isbitstype` Julia type is `NTuple{N, UInt32}`. When an array of this
514+
datatype is materialized with [`Base.getindex(ndarray::NDArray)`](@ref), each element is
515+
presented as a [`UCS4String`](@ref): a thin `AbstractString` view over those codepoints that
516+
displays and behaves as a Julia string, with identical bytes.
512517
513-
See also: [`AsciiDatatype`](@ref), [`parse_asdf_datatype`](@ref).
518+
See also: [`AsciiDatatype`](@ref), [`UCS4String`](@ref), [`parse_asdf_datatype`](@ref).
514519
"""
515520
struct Ucs4Datatype
516521
length::Int # Characters (4 bytes each)
@@ -788,9 +793,10 @@ Returns the fully materialized array. See [`ASDF.NDArray`](@ref) for definitions
788793
789794
```julia
790795
size(result) == Tuple(reverse(ndarray.shape))
791-
eltype(result) == Type(ndarray.datatype)
792-
sizeof(eltype) .* strides(result) == Tuple(reverse(ndarray.strides))
796+
eltype(result) == ASDF.materialized_eltype(ndarray.datatype)
793797
```
798+
799+
For the `ucs4` and `ascii` string datatypes, [`materialized_eltype`](@ref) is a thin `AbstractString` view over the characters ([`UCS4String`](@ref) / [`AsciiString`](@ref); see [`stringify_data`](@ref)). For all other datatypes, `eltype(result) == Type(ndarray.datatype)` and additionally `sizeof(eltype) .* strides(result) == Tuple(reverse(ndarray.strides))`.
794800
"""
795801
function Base.getindex(ndarray::NDArray)
796802
if ndarray.data !== nothing
@@ -831,6 +837,12 @@ function Base.getindex(ndarray::NDArray)
831837
error("`data` has different stride from `ndarray.strides`")
832838
end
833839

840+
# Present `ucs4`/`ascii` data as `UCS4String`/`AsciiString` rather than raw tuples of
841+
# codepoints. Done after the layout checks above, which rely on the `isbitstype` tuple
842+
# representation; the views are a zero-copy `reinterpret` sharing those bytes, so this is
843+
# display/behavior only.
844+
data = stringify_data(data, ndarray.datatype)
845+
834846
return data::AbstractArray
835847
end
836848

@@ -883,6 +895,113 @@ function correct_byteorder(data, ::Ucs4Datatype, byteorder::Byteorder)
883895
return data
884896
end
885897

898+
"""
899+
UCS4String{N} <: AbstractString
900+
901+
A fixed-width UCS-4 string of `N` characters, as materialized from an [`Ucs4Datatype`](@ref)
902+
array. It wraps the raw `NTuple{N, UInt32}` codepoints and is `isbitstype` with an identical
903+
memory layout, so it reinterprets to and from the on-disk block bytes exactly like the tuple
904+
would. The difference is purely in presentation: a `UCS4String` *displays* and *behaves* as a
905+
Julia string rather than as raw `UInt32` codepoints.
906+
907+
```jldoctest
908+
julia> s = ASDF.UCS4String((UInt32('h'), UInt32('i'), UInt32(0))) # null-padded
909+
"hi"
910+
911+
julia> s == "hi"
912+
true
913+
```
914+
915+
As a fixed-width string, trailing null (`\\0`) padding is treated as unused and excluded from
916+
the string's length and contents. The full `N` codepoints are retained internally so the
917+
on-disk representation round-trips unchanged.
918+
919+
See also: [`AsciiString`](@ref).
920+
"""
921+
struct UCS4String{N} <: AbstractString
922+
codes::NTuple{N, UInt32}
923+
end
924+
925+
"""
926+
AsciiString{N} <: AbstractString
927+
928+
A fixed-width ASCII string of `N` characters, as materialized from an [`AsciiDatatype`](@ref)
929+
array. The single-byte analogue of [`UCS4String`](@ref): it wraps the raw `NTuple{N, UInt8}`
930+
bytes and is `isbitstype` with an identical memory layout, reinterpreting to and from the
931+
on-disk block bytes exactly like the tuple would, while *displaying* and *behaving* as a Julia
932+
string rather than as raw `UInt8` bytes.
933+
934+
```jldoctest
935+
julia> s = ASDF.AsciiString((UInt8('h'), UInt8('i'), UInt8(0))) # null-padded
936+
"hi"
937+
938+
julia> s == "hi"
939+
true
940+
```
941+
942+
As with `UCS4String`, trailing null (`\\0`) padding is excluded from the string's length and
943+
contents, while the full `N` bytes are retained internally so the on-disk representation
944+
round-trips unchanged.
945+
"""
946+
struct AsciiString{N} <: AbstractString
947+
codes::NTuple{N, UInt8}
948+
end
949+
950+
# Both ASDF string views share the same fixed-width, null-padded, one-codeunit-per-character
951+
# `AbstractString` interface; only the code-unit type differs.
952+
const ASDFString = Union{UCS4String, AsciiString}
953+
954+
# Number of significant characters, i.e. `N` minus any trailing null padding.
955+
function significant_length(s::ASDFString)
956+
n = length(s.codes)
957+
@inbounds while n > 0 && s.codes[n] == 0
958+
n -= 1
959+
end
960+
return n
961+
end
962+
963+
Base.ncodeunits(s::ASDFString) = significant_length(s)
964+
Base.codeunit(::UCS4String) = UInt32
965+
Base.codeunit(::AsciiString) = UInt8
966+
function Base.codeunit(s::ASDFString, i::Integer)
967+
@boundscheck 1 <= i <= ncodeunits(s) || throw(BoundsError(s, i))
968+
return @inbounds s.codes[i]
969+
end
970+
Base.isvalid(s::ASDFString, i::Integer) = 1 <= i <= ncodeunits(s)
971+
Base.length(s::ASDFString) = ncodeunits(s)
972+
function Base.iterate(s::ASDFString, i::Int = 1)
973+
# State is the code-unit index, per the `AbstractString` interface (`getindex` calls
974+
# `iterate(s, i::Integer)`), so the significant length is rechecked each step.
975+
i > ncodeunits(s) && return nothing
976+
u = s.codes[i]
977+
isvalid(Char, u) || error("$(typeof(s)) contains an invalid Unicode codepoint $(repr(u)) at index $i")
978+
return (Char(u), i + 1)
979+
end
980+
981+
"""
982+
stringify_data(data, datatype) -> AbstractArray
983+
984+
Present a materialized [`Ucs4Datatype`](@ref) array as [`UCS4String`](@ref)s, and an
985+
[`AsciiDatatype`](@ref) array as [`AsciiString`](@ref)s, instead of raw tuples of codepoints.
986+
For any other datatype the data is returned unchanged. The string views are `isbitstype` with
987+
the same layout as the backing tuples, so this is a zero-copy `reinterpret`; only display and
988+
behavior change, and writing is unaffected.
989+
"""
990+
stringify_data(data, datatype) = data
991+
stringify_data(data, dt::Ucs4Datatype) = reinterpret(materialized_eltype(dt), data)
992+
stringify_data(data, dt::AsciiDatatype) = reinterpret(materialized_eltype(dt), data)
993+
994+
"""
995+
materialized_eltype(datatype) -> Type
996+
997+
Julia element type produced by materializing an array of the given ASDF `datatype`. This is
998+
[`UCS4String{N}`](@ref UCS4String) for [`Ucs4Datatype`](@ref), [`AsciiString{N}`](@ref AsciiString)
999+
for [`AsciiDatatype`](@ref), and `Type(datatype)` otherwise.
1000+
"""
1001+
materialized_eltype(dt) = Type(dt)
1002+
materialized_eltype(dt::Ucs4Datatype) = UCS4String{dt.length}
1003+
materialized_eltype(dt::AsciiDatatype) = AsciiString{dt.length}
1004+
8861005
"""
8871006
TaggedMapping{D} <: AbstractDict
8881007
TaggedSequence{V} <: AbstractVector
@@ -897,8 +1016,10 @@ struct TaggedMapping{D<:AbstractDict} <: AbstractDict{Any, Any}
8971016
tag::String
8981017
value::D
8991018
end
900-
# `keys`, `values`, `haskey`, `get`, etc. fall back to these via the `AbstractDict` interface.
1019+
# `keys`, `values`, `haskey`, etc. fall back to these via the `AbstractDict` interface.
1020+
# `get` is delegated explicitly because the `haskey`/`get` fallbacks need a 3-arg `get`.
9011021
Base.getindex(m::TaggedMapping, k) = getindex(m.value, k)
1022+
Base.get(m::TaggedMapping, k, default) = get(m.value, k, default)
9021023
Base.length(m::TaggedMapping) = length(m.value)
9031024
Base.iterate(m::TaggedMapping, state...) = iterate(m.value, state...)
9041025

@@ -1060,7 +1181,7 @@ Allocates a dense array of shape `reverse(shape)` and fills it by calling `chunk
10601181
"""
10611182
function Base.getindex(chunked_ndarray::ChunkedNDArray)
10621183
shape = chunked_ndarray.shape
1063-
datatype = Type(chunked_ndarray.datatype)
1184+
datatype = materialized_eltype(chunked_ndarray.datatype)
10641185
data = Array{datatype}(undef, reverse(shape)...)
10651186
for chunk in chunked_ndarray.chunks
10661187
start = CartesianIndex(reverse(chunk.start .+ 1)...)
@@ -1395,8 +1516,8 @@ const blocks::Blocks = Blocks()
13951516
13961517
Infer the ASDF datatype from a Julia element type, used when writing arrays:
13971518
1398-
- `NTuple{N, UInt8}` --> [`AsciiDatatype(N)`](@ref AsciiDatatype)
1399-
- `NTuple{N, UInt32}` --> [`Ucs4Datatype(N)`](@ref Ucs4Datatype)
1519+
- `NTuple{N, UInt8}` or [`AsciiString{N}`](@ref AsciiString) --> [`AsciiDatatype(N)`](@ref AsciiDatatype)
1520+
- `NTuple{N, UInt32}` or [`UCS4String{N}`](@ref UCS4String) --> [`Ucs4Datatype(N)`](@ref Ucs4Datatype)
14001521
- `NamedTuple` --> [`StructuredDatatype`](@ref) with fields inferred recursively
14011522
- Any other type --> [`Datatype`](@ref) via the existing `type_datatype_dict` lookup
14021523
@@ -1419,6 +1540,11 @@ function infer_asdf_datatype(T::Type)::Union{Datatype, AsciiDatatype, Ucs4Dataty
14191540
end
14201541
end
14211542

1543+
# `UCS4String{N}`/`AsciiString{N}` round-trip to the datatypes they were materialized from; the
1544+
# width `N` is the type parameter, recovered directly by dispatch (the inverse of `materialized_eltype`).
1545+
infer_asdf_datatype(::Type{UCS4String{N}}) where {N} = Ucs4Datatype(N)
1546+
infer_asdf_datatype(::Type{AsciiString{N}}) where {N} = AsciiDatatype(N)
1547+
14221548
function YAML._print(io::IO, val::NDArrayWrapper, level::Int=0, ignore_level::Bool=false)
14231549
datatype = infer_asdf_datatype(eltype(val.array))
14241550
if val.inline

test/test-datatypes.jl

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# Unit tests for the ASDF string/structured datatype helpers and the write-side
2+
# `infer_asdf_datatype` inference. The reference roundtrips exercise these indirectly,
3+
# but the methods below are pinned down here so they stay covered without large fixtures.
4+
5+
@testset "string datatype interface" begin
6+
ucs = ASDF.UCS4String((UInt32('h'), UInt32('i'), UInt32(0))) # null-padded
7+
asc = ASDF.AsciiString((UInt8('h'), UInt8('i'), UInt8(0)))
8+
9+
# `codeunit(::Type)` reports the code-unit type for each string view.
10+
@test codeunit(ucs) == UInt32
11+
@test codeunit(asc) == UInt8
12+
13+
# Indexed `codeunit` returns the raw code unit at that position.
14+
@test codeunit(ucs, 1) == UInt32('h')
15+
@test codeunit(ucs, 2) == UInt32('i')
16+
@test codeunit(asc, 1) == UInt8('h')
17+
@test codeunit(asc, 2) == UInt8('i')
18+
19+
# Trailing null padding is excluded from the significant length.
20+
@test ncodeunits(ucs) == 2
21+
@test ncodeunits(asc) == 2
22+
23+
# `isvalid` is bounded by the significant length, not the full tuple width.
24+
@test isvalid(ucs, 1)
25+
@test isvalid(ucs, 2)
26+
@test !isvalid(ucs, 3) # padding byte
27+
@test !isvalid(ucs, 0)
28+
@test isvalid(asc, 2)
29+
@test !isvalid(asc, 3)
30+
31+
@test ucs == "hi"
32+
@test asc == "hi"
33+
34+
# Embedded (non-trailing) nulls are kept: `significant_length` strips only *trailing*
35+
# nulls, so the significant length runs through to the last non-null code unit.
36+
ucs_embedded = ASDF.UCS4String((UInt32('h'), UInt32(0), UInt32('i')))
37+
asc_embedded = ASDF.AsciiString((UInt8('h'), UInt8(0), UInt8('i')))
38+
@test ncodeunits(ucs_embedded) == 3
39+
@test ncodeunits(asc_embedded) == 3
40+
@test ucs_embedded == "h\0i"
41+
@test asc_embedded == "h\0i"
42+
end
43+
44+
@testset "materialized_eltype" begin
45+
@test ASDF.materialized_eltype(ASDF.Ucs4Datatype(3)) == ASDF.UCS4String{3}
46+
@test ASDF.materialized_eltype(ASDF.AsciiDatatype(4)) == ASDF.AsciiString{4}
47+
# Plain scalar datatypes fall through to the Julia type.
48+
@test ASDF.materialized_eltype(ASDF.Datatype_float32) == Float32
49+
end
50+
51+
@testset "infer_asdf_datatype" begin
52+
# Raw `NTuple` byte/codepoint forms map to the ASDF string datatypes.
53+
@test ASDF.infer_asdf_datatype(NTuple{2, UInt8}) == ASDF.AsciiDatatype(2)
54+
@test ASDF.infer_asdf_datatype(NTuple{2, UInt32}) == ASDF.Ucs4Datatype(2)
55+
56+
# The string *views* recover the same datatypes as their backing tuples.
57+
@test ASDF.infer_asdf_datatype(ASDF.AsciiString{5}) == ASDF.AsciiDatatype(5)
58+
@test ASDF.infer_asdf_datatype(ASDF.UCS4String{6}) == ASDF.Ucs4Datatype(6)
59+
60+
# `NamedTuple` becomes a structured datatype with fields inferred recursively.
61+
sd = ASDF.infer_asdf_datatype(@NamedTuple{x::Float32, label::NTuple{4, UInt8}})
62+
@test sd isa ASDF.StructuredDatatype
63+
@test [f.name for f in sd.fields] == ["x", "label"]
64+
@test sd.fields[1].datatype == ASDF.Datatype_float32
65+
@test sd.fields[2].datatype == ASDF.AsciiDatatype(4)
66+
67+
# Plain scalar types defer to the existing dict lookup.
68+
@test ASDF.infer_asdf_datatype(Int64) == ASDF.Datatype_int64
69+
end
70+
71+
@testset "tagged node accessors" begin
72+
# The `Tagged*` wrappers delegate their collection / string interfaces to the wrapped
73+
# value. Loading round-trips them (see test-read_fallbacks), but the individual delegated
74+
# methods are pinned down directly here so they stay covered across platforms.
75+
tm = ASDF.TaggedMapping("tag:example.org:mylib/widget-1.0.0", Dict("a" => 1, "b" => 2))
76+
ts = ASDF.TaggedSequence("tag:example.org:mylib/series-1.0.0", ["alpha", "beta", "gamma"])
77+
tsc = ASDF.TaggedScalar("tag:example.org:mylib/quantity-1.0.0", "3.14")
78+
79+
# TaggedMapping behaves as its underlying dict.
80+
@test length(tm) == 2
81+
@test tm["a"] == 1
82+
@test haskey(tm, "b")
83+
@test !haskey(tm, "missing")
84+
@test get(tm, "b", 0) == 2
85+
@test get(tm, "missing", -1) == -1
86+
87+
# TaggedSequence behaves as its underlying vector, with linear indexing.
88+
@test Base.IndexStyle(typeof(ts)) == IndexLinear()
89+
@test Base.IndexStyle(ASDF.TaggedSequence) == IndexLinear()
90+
@test size(ts) == (3,)
91+
@test ts[2] == "beta"
92+
93+
# TaggedScalar behaves as its underlying string.
94+
@test ncodeunits(tsc) == ncodeunits("3.14")
95+
@test codeunit(tsc) == UInt8
96+
@test codeunit(tsc, 1) == codeunit("3.14", 1)
97+
@test isvalid(tsc, 1)
98+
@test !isvalid(tsc, 99)
99+
@test tsc == "3.14"
100+
end

test/test-ndarray.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ end
103103
@test nd[] == expected
104104

105105
nd = make_ndarray(; lazy_block_headers = lbh, source = Int64(0), data = nothing, byteorder = opposite, datatype = ASDF.Ucs4Datatype(2), shape = [Int64(1)], strides = [Int64(8)])
106-
@test nd[] == [(UInt32(0x00000001), UInt32(0x00000002))]
106+
# ucs4 data is presented as a string-like `UCS4String`, byte-swapped to host order
107+
@test eltype(nd[]) <: AbstractString
108+
@test nd[] == [join((Char(0x1), Char(0x2)))]
107109
end
108110

109111
nd = make_ndarray(; strides = Int64[5])

0 commit comments

Comments
 (0)