mirror of
https://github.com/nosqlbench/nosqlbench.git
synced 2026-10-12 09:24:52 -05:00
feat(vectordata): bind records to operation parameters
The scaffolding projective functions build on: a Layout learned once from a facet's first record, a Binder compiled against it that binds by position with parameter renames applied at compile time, forms read from the facet's forms namespace with one implicit form when it has none, and a PredicateBinder that types each condition from the field's tag rather than the comparand's variant and refuses a record whose leader byte or shape disagrees with the template. BindType is its own exhaustive mapping from the tag, distinct from the rendering one.
This commit is contained in:
1 parent
0f1b0e8a30
commit
28b70170bc
10 files changed
+1245
No files matched your search
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (c) 2026 The NoSQLBench Authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.nosqlbench.vectordata.binding;
|
||||
|
||||
import io.nosqlbench.vectordata.VectorDataException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/// What went wrong preparing or binding. The [Kind] is matchable, and
|
||||
/// each carries what a caller needs to act, not only text to print.
|
||||
public class BindException extends VectorDataException {
|
||||
public enum Kind {
|
||||
/// The facet could not be read.
|
||||
FACET,
|
||||
/// No record was available to learn the layout from.
|
||||
NO_LAYOUT,
|
||||
/// A record could not be walked, or is not what the binder was compiled for.
|
||||
RECORD,
|
||||
/// A parameter names a field the facet does not have.
|
||||
NO_SUCH_FIELD,
|
||||
/// A form was asked for that this facet does not offer.
|
||||
NO_SUCH_FORM
|
||||
}
|
||||
|
||||
private final Kind kind;
|
||||
private final String name;
|
||||
private final List<String> available;
|
||||
|
||||
private BindException(Kind kind, String message, String name, List<String> available) {
|
||||
super(message);
|
||||
this.kind = kind; this.name = name; this.available = List.copyOf(available);
|
||||
}
|
||||
|
||||
public Kind kind() { return kind; }
|
||||
/// The field or form asked for, for `NO_SUCH_FIELD` and `NO_SUCH_FORM`;
|
||||
/// the facet, for `NO_LAYOUT`.
|
||||
public String name() { return name; }
|
||||
/// What the facet does have, in order, for `NO_SUCH_FIELD` and
|
||||
/// `NO_SUCH_FORM` — empty for a form when only the implicit one exists.
|
||||
public List<String> available() { return available; }
|
||||
|
||||
public static BindException facet(String message) { return new BindException(Kind.FACET, message, null, List.of()); }
|
||||
public static BindException noLayout(String facet) { return new BindException(Kind.NO_LAYOUT, facet + ": no record to learn the field layout from", facet, List.of()); }
|
||||
public static BindException record(String message) { return new BindException(Kind.RECORD, message, null, List.of()); }
|
||||
public static BindException noSuchField(String field, List<String> available) {
|
||||
return new BindException(Kind.NO_SUCH_FIELD, "no field '" + field + "' in this facet; it has: " + String.join(", ", available), field, available);
|
||||
}
|
||||
public static BindException noSuchForm(String form, List<String> available) {
|
||||
String offers = available.isEmpty() ? "(only its implicit form)" : String.join(", ", available);
|
||||
return new BindException(Kind.NO_SUCH_FORM, "this facet offers no form '" + form + "'; it offers: " + offers, form, available);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (c) 2026 The NoSQLBench Authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.nosqlbench.vectordata.binding;
|
||||
|
||||
import io.nosqlbench.vectordata.anode.TypeTag;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/// The type a parameter binds as.
|
||||
///
|
||||
/// **Distinct from the rendering mapping**, deliberately. The schema
|
||||
/// vernaculars answer "what column would hold this" and are correct as
|
||||
/// DDL; they map `Half` to `smallint`, give `Null` a type of `text`,
|
||||
/// and collapse container element types. Binding a value through those
|
||||
/// answers would send a float's bit pattern as an integer and every
|
||||
/// collection's elements as strings.
|
||||
///
|
||||
/// Dialect-neutral: this names what the value *is*, and an adapter maps
|
||||
/// it to a driver's type. The mapping from [TypeTag] in [#ofTag] is an
|
||||
/// exhaustive switch with no default arm, so a new tag fails to compile
|
||||
/// until it is handled there.
|
||||
public final class BindType {
|
||||
/// The kinds a value binds as. Containers carry their element
|
||||
/// types on the [BindType], when known.
|
||||
public enum Kind {
|
||||
TEXT, ASCII, BOOL, BLOB, INT16, INT32, INT64, FLOAT16, FLOAT32, FLOAT64, DECIMAL, VARINT,
|
||||
DATE, TIME, TIMESTAMP_MILLIS, TIMESTAMP_NANOS, TIMESTAMP_TEXT, TIME_UUID, UUID, ULID, NULL,
|
||||
LIST, SET, MAP
|
||||
}
|
||||
|
||||
public static final BindType TEXT = new BindType(Kind.TEXT, null, null, null);
|
||||
public static final BindType ASCII = new BindType(Kind.ASCII, null, null, null);
|
||||
public static final BindType BOOL = new BindType(Kind.BOOL, null, null, null);
|
||||
public static final BindType BLOB = new BindType(Kind.BLOB, null, null, null);
|
||||
public static final BindType INT16 = new BindType(Kind.INT16, null, null, null);
|
||||
public static final BindType INT32 = new BindType(Kind.INT32, null, null, null);
|
||||
public static final BindType INT64 = new BindType(Kind.INT64, null, null, null);
|
||||
public static final BindType FLOAT16 = new BindType(Kind.FLOAT16, null, null, null);
|
||||
public static final BindType FLOAT32 = new BindType(Kind.FLOAT32, null, null, null);
|
||||
public static final BindType FLOAT64 = new BindType(Kind.FLOAT64, null, null, null);
|
||||
public static final BindType DECIMAL = new BindType(Kind.DECIMAL, null, null, null);
|
||||
public static final BindType VARINT = new BindType(Kind.VARINT, null, null, null);
|
||||
public static final BindType DATE = new BindType(Kind.DATE, null, null, null);
|
||||
public static final BindType TIME = new BindType(Kind.TIME, null, null, null);
|
||||
/// An instant, in milliseconds.
|
||||
public static final BindType TIMESTAMP_MILLIS = new BindType(Kind.TIMESTAMP_MILLIS, null, null, null);
|
||||
/// An instant, in seconds plus a nanosecond adjustment.
|
||||
public static final BindType TIMESTAMP_NANOS = new BindType(Kind.TIMESTAMP_NANOS, null, null, null);
|
||||
/// An instant carried as text. Distinct from the numeric timestamps
|
||||
/// because that is what it is on the wire, and a parameter typed as
|
||||
/// a number would be bound from a string.
|
||||
public static final BindType TIMESTAMP_TEXT = new BindType(Kind.TIMESTAMP_TEXT, null, null, null);
|
||||
/// A UUID whose ordering carries a timestamp.
|
||||
public static final BindType TIME_UUID = new BindType(Kind.TIME_UUID, null, null, null);
|
||||
public static final BindType UUID = new BindType(Kind.UUID, null, null, null);
|
||||
public static final BindType ULID = new BindType(Kind.ULID, null, null, null);
|
||||
/// Absent. Carries no type of its own — the parameter's type comes
|
||||
/// from the schema, not from the null.
|
||||
public static final BindType NULL = new BindType(Kind.NULL, null, null, null);
|
||||
|
||||
private final Kind kind;
|
||||
private final BindType element;
|
||||
private final BindType key;
|
||||
private final BindType value;
|
||||
|
||||
private BindType(Kind kind, BindType element, BindType key, BindType value) { this.kind = kind; this.element = element; this.key = key; this.value = value; }
|
||||
|
||||
/// An ordered collection; `element` is `null` when the tag alone
|
||||
/// does not determine it — only a value knows what a list holds.
|
||||
public static BindType list(BindType element) { return new BindType(Kind.LIST, element, null, null); }
|
||||
/// An unordered collection.
|
||||
public static BindType set(BindType element) { return new BindType(Kind.SET, element, null, null); }
|
||||
/// Key/value pairs.
|
||||
public static BindType map(BindType key, BindType value) { return new BindType(Kind.MAP, null, key, value); }
|
||||
|
||||
public Kind kind() { return kind; }
|
||||
/// The element type of a list or set, or `null` when unknown or not a collection.
|
||||
public BindType element() { return element; }
|
||||
/// The key type of a map, or `null`.
|
||||
public BindType key() { return key; }
|
||||
/// The value type of a map, or `null`.
|
||||
public BindType value() { return value; }
|
||||
|
||||
/// The bind type a tag implies.
|
||||
///
|
||||
/// Container element types come back unknown: a tag says a field
|
||||
/// holds a list, and only the value says a list of what. Reporting
|
||||
/// that honestly is the point — the rendering mapping guesses
|
||||
/// `text` there, which is fine in a `CREATE TABLE` and wrong in a
|
||||
/// bind.
|
||||
public static BindType ofTag(TypeTag tag) {
|
||||
return switch (tag) {
|
||||
case TEXT, TEXT_VALIDATED, ENUM_STR -> TEXT;
|
||||
case ASCII -> ASCII;
|
||||
case BOOL -> BOOL;
|
||||
case BYTES -> BLOB;
|
||||
case SHORT -> INT16;
|
||||
case INT32, ENUM_ORD -> INT32;
|
||||
case INT -> INT64;
|
||||
// A 16-bit float is a float. The rendering mapping calls it
|
||||
// a smallint, which would bind the bit pattern.
|
||||
case HALF -> FLOAT16;
|
||||
case FLOAT32 -> FLOAT32;
|
||||
case FLOAT -> FLOAT64;
|
||||
case DECIMAL -> DECIMAL;
|
||||
case VARINT -> VARINT;
|
||||
case DATE -> DATE;
|
||||
case TIME -> TIME;
|
||||
case MILLIS -> TIMESTAMP_MILLIS;
|
||||
case NANOS -> TIMESTAMP_NANOS;
|
||||
// Textual on the wire, whatever the name suggests.
|
||||
case DATETIME -> TIMESTAMP_TEXT;
|
||||
case UUID_V1 -> TIME_UUID;
|
||||
case UUID_V7 -> UUID;
|
||||
case ULID -> ULID;
|
||||
case NULL -> NULL;
|
||||
case LIST, ARRAY -> list(null);
|
||||
case SET -> set(null);
|
||||
case MAP, TYPED_MAP -> map(null, null);
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether this type is still missing an element type.
|
||||
///
|
||||
/// A layout built from tags alone leaves containers open; a caller
|
||||
/// preparing a statement against one needs either a form that says
|
||||
/// what they hold, or a sample value.
|
||||
public boolean isUnderdetermined() {
|
||||
return switch (kind) {
|
||||
case LIST, SET -> element == null;
|
||||
case MAP -> key == null || value == null;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
@Override public boolean equals(Object other) {
|
||||
return other instanceof BindType b && kind == b.kind && Objects.equals(element, b.element) && Objects.equals(key, b.key) && Objects.equals(value, b.value);
|
||||
}
|
||||
@Override public int hashCode() { return Objects.hash(kind, element, key, value); }
|
||||
@Override public String toString() {
|
||||
String name = kind.name().toLowerCase(Locale.ROOT);
|
||||
return switch (kind) {
|
||||
case LIST, SET -> name + "<" + (element == null ? "?" : element) + ">";
|
||||
case MAP -> name + "<" + (key == null ? "?" : key) + ", " + (value == null ? "?" : value) + ">";
|
||||
default -> name;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (c) 2026 The NoSQLBench Authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.nosqlbench.vectordata.binding;
|
||||
|
||||
import io.nosqlbench.vectordata.anode.Field;
|
||||
import io.nosqlbench.vectordata.anode.Scan;
|
||||
import io.nosqlbench.vectordata.anode.ScanException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/// A compiled binding: which positions to read, and what to call them.
|
||||
///
|
||||
/// Built once from a [Layout], then used for every record. The
|
||||
/// per-record path never looks at a field name — that work is done
|
||||
/// here, which is what keeps binding free of per-record name
|
||||
/// resolution. Binding is not rendering: the vernaculars inline
|
||||
/// literals into text for a human, and this hands named, typed values
|
||||
/// to a driver with the statement prepared once.
|
||||
public final class Binder {
|
||||
/// Positions to bind, in parameter order.
|
||||
private final int[] positions;
|
||||
/// Parameter names, in the same order.
|
||||
private final List<String> parameters;
|
||||
/// What each parameter binds as.
|
||||
private final List<BindType> types;
|
||||
|
||||
private Binder(int[] positions, List<String> parameters, List<BindType> types) {
|
||||
this.positions = positions; this.parameters = List.copyOf(parameters); this.types = List.copyOf(types);
|
||||
}
|
||||
|
||||
/// Binds every field of the layout, under its own name.
|
||||
public static Binder all(Layout layout) {
|
||||
int[] positions = new int[layout.names().size()];
|
||||
for (int i = 0; i < positions.length; i++) positions[i] = i;
|
||||
return new Binder(positions, layout.names(), layout.types());
|
||||
}
|
||||
|
||||
/// Binds the named fields, in the order given.
|
||||
///
|
||||
/// A field the facet does not have is refused here, at compile
|
||||
/// time, naming what the facet does have — rather than binding a
|
||||
/// missing value on the first cycle.
|
||||
public static Binder select(Layout layout, List<String> fields) {
|
||||
int[] positions = new int[fields.size()];
|
||||
List<String> parameters = new ArrayList<>(fields.size());
|
||||
List<BindType> types = new ArrayList<>(fields.size());
|
||||
for (int i = 0; i < fields.size(); i++) {
|
||||
int at = layout.positionOf(fields.get(i));
|
||||
if (at < 0) throw BindException.noSuchField(fields.get(i), layout.names());
|
||||
positions[i] = at;
|
||||
parameters.add(layout.names().get(at));
|
||||
types.add(layout.types().get(at));
|
||||
}
|
||||
return new Binder(positions, parameters, types);
|
||||
}
|
||||
|
||||
/// [#select] with the fields as arguments.
|
||||
public static Binder select(Layout layout, String... fields) { return select(layout, Arrays.asList(fields)); }
|
||||
|
||||
/// Renames parameters for substitution.
|
||||
///
|
||||
/// The metadata name is the parameter name unless a runtime says
|
||||
/// otherwise. An override is applied here, against resolved
|
||||
/// positions, so it costs nothing per record and cannot reach the
|
||||
/// data — the field keeps its name in the facet.
|
||||
public Binder withOverrides(Map<String, String> overrides) {
|
||||
List<String> renamed = new ArrayList<>(parameters.size());
|
||||
for (String p : parameters) renamed.add(overrides.getOrDefault(p, p));
|
||||
return new Binder(positions, renamed, types);
|
||||
}
|
||||
|
||||
/// Parameter names, in bind order. Read once, to prepare a statement.
|
||||
public List<String> parameters() { return parameters; }
|
||||
/// What each parameter binds as, in bind order.
|
||||
public List<BindType> types() { return types; }
|
||||
|
||||
/// Receives one bound field: the parameter slot it fills, in bind
|
||||
/// order, and the field viewed in place.
|
||||
@FunctionalInterface
|
||||
public interface FieldSink { void accept(int slot, Field field); }
|
||||
|
||||
/// Binds one record, handing each parameter to `sink` in the order
|
||||
/// the walk meets it.
|
||||
///
|
||||
/// The form for a cycle loop: nothing is copied and no field name
|
||||
/// is looked at. Each field is a small view over the record's own
|
||||
/// bytes, valid for as long as the record is — handed over rather
|
||||
/// than kept, so nothing outlives the record it came from.
|
||||
public void bindEach(byte[] record, FieldSink sink) {
|
||||
int seen = 0;
|
||||
try {
|
||||
for (Field field : Scan.fields(record)) {
|
||||
for (int slot = 0; slot < positions.length; slot++) {
|
||||
if (positions[slot] == field.index()) { sink.accept(slot, field); seen++; }
|
||||
}
|
||||
}
|
||||
} catch (ScanException e) {
|
||||
throw BindException.record(e.getMessage());
|
||||
}
|
||||
if (seen < positions.length)
|
||||
throw BindException.record("record has " + seen + " of the " + positions.length + " bound fields — its layout differs from the one this binder was compiled against");
|
||||
}
|
||||
|
||||
/// Binds one record, returning its bound fields in bind order.
|
||||
///
|
||||
/// For a caller that wants random access to the bound values. The
|
||||
/// list is one walk's worth of views over this record's bytes and
|
||||
/// is scoped to it; see [#bindEach] for the loop form.
|
||||
public List<Field> bind(byte[] record) {
|
||||
// One walk, picking out the wanted positions, so a template may
|
||||
// name fields in any order without costing a second pass.
|
||||
Field[] found = new Field[positions.length];
|
||||
try {
|
||||
for (Field field : Scan.fields(record)) {
|
||||
for (int slot = 0; slot < positions.length; slot++) if (positions[slot] == field.index()) found[slot] = field;
|
||||
}
|
||||
} catch (ScanException e) {
|
||||
throw BindException.record(e.getMessage());
|
||||
}
|
||||
for (int slot = 0; slot < found.length; slot++)
|
||||
if (found[slot] == null) throw BindException.noSuchField(parameters.get(slot), List.of());
|
||||
return List.of(found);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2026 The NoSQLBench Authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.nosqlbench.vectordata.binding;
|
||||
|
||||
import io.nosqlbench.vectordata.anode.PNode.OpType;
|
||||
|
||||
/// One condition of a predicate, prepared. The statement fragment is
|
||||
/// `field op ?`; only the comparands move per record.
|
||||
///
|
||||
/// @param field the metadata field this condition constrains
|
||||
/// @param parameter what to call the parameter: the field's name unless a runtime overrode it
|
||||
/// @param op the comparison
|
||||
/// @param bindType what the parameter binds as — **from the field's tag,
|
||||
/// not the comparand's variant**. An [io.nosqlbench.vectordata.anode.MValue]
|
||||
/// has 29 variants and a comparand six, so a predicate over a `DateTime`
|
||||
/// field carries an `Int` comparand; typing the parameter from that
|
||||
/// would collapse every temporal and UUID column to a bigint or a string
|
||||
/// @param arity how many comparands this condition carries: one for the
|
||||
/// scalar operators, a membership set for `IN`
|
||||
public record Condition(String field, String parameter, OpType op, BindType bindType, int arity) { }
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (c) 2026 The NoSQLBench Authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.nosqlbench.vectordata.binding;
|
||||
|
||||
import io.nosqlbench.vectordata.anode.ANode;
|
||||
import io.nosqlbench.vectordata.anode.ANodeException;
|
||||
import io.nosqlbench.vectordata.anode.MNode;
|
||||
import io.nosqlbench.vectordata.anode.MValue;
|
||||
import io.nosqlbench.vectordata.anode.Vernacular;
|
||||
import io.nosqlbench.vectordata.anode.Vernaculars;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/// One op-template form a facet offers: what operation its records can
|
||||
/// become, which fields that binds, and what to call them.
|
||||
///
|
||||
/// Open by construction: keys this build does not know are kept in
|
||||
/// [#extra] rather than rejected. A writer recording a form this build
|
||||
/// does not implement is recording, not misbehaving.
|
||||
public final class Form {
|
||||
/// The name an unnamed, implicit form goes by.
|
||||
public static final String IMPLICIT = "default";
|
||||
|
||||
private final String name;
|
||||
private final String kind;
|
||||
private final String wireFormat;
|
||||
private final String operation;
|
||||
private final List<String> fields;
|
||||
private final Map<String, String> parameters;
|
||||
private final Map<String, MValue> extra;
|
||||
|
||||
public Form(String name, String kind, String wireFormat, String operation, List<String> fields, Map<String, String> parameters, Map<String, MValue> extra) {
|
||||
this.name = name; this.kind = kind; this.wireFormat = wireFormat; this.operation = operation;
|
||||
this.fields = fields == null ? null : List.copyOf(fields);
|
||||
this.parameters = Collections.unmodifiableMap(new LinkedHashMap<>(parameters));
|
||||
this.extra = Collections.unmodifiableMap(new LinkedHashMap<>(extra));
|
||||
}
|
||||
|
||||
/// The form a facet with no `forms` namespace offers: every field,
|
||||
/// under its own name.
|
||||
public static Form implicit() { return new Form(IMPLICIT, null, null, null, null, Map.of(), Map.of()); }
|
||||
|
||||
/// Reads a form from its JSON record: `name` required; `kind`,
|
||||
/// `wire_format`, `operation` optional strings; `fields` an
|
||||
/// optional list of names; `parameters` an optional map of
|
||||
/// renames; every other key preserved as it was written.
|
||||
public static Form fromJson(String json) {
|
||||
ANode node = Vernaculars.parse(json, Vernacular.JSON);
|
||||
MNode m = ((ANode.M) node).node();
|
||||
String name = null, kind = null, wireFormat = null, operation = null;
|
||||
List<String> fields = null;
|
||||
Map<String, String> parameters = new LinkedHashMap<>();
|
||||
Map<String, MValue> extra = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, MValue> e : m.fields().entrySet()) {
|
||||
switch (e.getKey()) {
|
||||
case "name" -> name = text(e);
|
||||
case "kind" -> kind = optionalText(e);
|
||||
case "wire_format" -> wireFormat = optionalText(e);
|
||||
case "operation" -> operation = optionalText(e);
|
||||
case "fields" -> {
|
||||
if (e.getValue() instanceof MValue.Null) break;
|
||||
if (!(e.getValue() instanceof MValue.ListValue list)) throw new ANodeException("form 'fields' must be a list of names");
|
||||
fields = new ArrayList<>();
|
||||
for (MValue item : list.items()) {
|
||||
if (!(item instanceof MValue.Text t)) throw new ANodeException("form 'fields' must be a list of names");
|
||||
fields.add(t.value());
|
||||
}
|
||||
}
|
||||
case "parameters" -> {
|
||||
if (e.getValue() instanceof MValue.Null) break;
|
||||
if (!(e.getValue() instanceof MValue.MapValue map)) throw new ANodeException("form 'parameters' must be a map of names");
|
||||
for (Map.Entry<String, MValue> p : map.node().fields().entrySet()) {
|
||||
if (!(p.getValue() instanceof MValue.Text t)) throw new ANodeException("form 'parameters' must be a map of names");
|
||||
parameters.put(p.getKey(), t.value());
|
||||
}
|
||||
}
|
||||
default -> extra.put(e.getKey(), e.getValue());
|
||||
}
|
||||
}
|
||||
if (name == null) throw new ANodeException("form record has no 'name'");
|
||||
return new Form(name, kind, wireFormat, operation, fields, parameters, extra);
|
||||
}
|
||||
|
||||
private static String text(Map.Entry<String, MValue> e) {
|
||||
if (e.getValue() instanceof MValue.Text t) return t.value();
|
||||
throw new ANodeException("form '" + e.getKey() + "' must be a string");
|
||||
}
|
||||
|
||||
private static String optionalText(Map.Entry<String, MValue> e) { return e.getValue() instanceof MValue.Null ? null : text(e); }
|
||||
|
||||
/// The form's name, as selected.
|
||||
public String name() { return name; }
|
||||
/// Which dialect of record this form is for — `metadata` or
|
||||
/// `predicate` — or `null`. Advisory: the record's leader byte
|
||||
/// remains the authority on what a record is.
|
||||
public String kind() { return kind; }
|
||||
/// The record encoding this form consumes, e.g. `mnode:v1`, or `null`.
|
||||
public String wireFormat() { return wireFormat; }
|
||||
/// What operation the bound record becomes, e.g. `insert`, or `null`.
|
||||
public String operation() { return operation; }
|
||||
/// Fields this form binds, in bind order; `null` means all of them,
|
||||
/// in the facet's own order.
|
||||
public List<String> fields() { return fields; }
|
||||
/// Parameter renames, keyed by field name.
|
||||
public Map<String, String> parameters() { return parameters; }
|
||||
/// Anything this build does not recognise, preserved as written.
|
||||
public Map<String, MValue> extra() { return extra; }
|
||||
|
||||
/// Compiles this form against a layout.
|
||||
public Binder binder(Layout layout) {
|
||||
Binder binder = fields == null ? Binder.all(layout) : Binder.select(layout, fields);
|
||||
return binder.withOverrides(parameters);
|
||||
}
|
||||
|
||||
@Override public String toString() { return "Form[" + name + (operation == null ? "" : " " + operation) + (fields == null ? "" : " " + fields) + "]"; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2026 The NoSQLBench Authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.nosqlbench.vectordata.binding;
|
||||
|
||||
import io.nosqlbench.vectordata.records.RecordException;
|
||||
import io.nosqlbench.vectordata.records.RecordFacet;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/// The forms a facet offers, read from its `forms` namespace.
|
||||
///
|
||||
/// Absent from every dataset written before forms existed, and absent
|
||||
/// means one implicit form rather than none: every such dataset must
|
||||
/// keep binding unchanged. Absence is not an empty set.
|
||||
public final class Forms {
|
||||
private static final Logger LOG = Logger.getLogger(Forms.class.getName());
|
||||
|
||||
/// The slab namespace enumerating the op-template forms a facet offers.
|
||||
public static final String NAMESPACE = "forms";
|
||||
|
||||
private Forms() { }
|
||||
|
||||
/// The forms a facet offers: those its `forms` namespace declares,
|
||||
/// or the implicit one when it declares none.
|
||||
public static List<Form> of(RecordFacet facet) {
|
||||
try {
|
||||
RecordFacet ns = facet.namespace(NAMESPACE);
|
||||
long count = ns.count();
|
||||
if (count == 0) return List.of(Form.implicit());
|
||||
List<Form> out = new ArrayList<>();
|
||||
for (long o = 0; o < count; o++) {
|
||||
byte[] bytes = ns.recordBytes(o);
|
||||
// A form record this build cannot parse is skipped rather
|
||||
// than failing the facet: the forms it *can* read are
|
||||
// still usable, and refusing all of them over one would
|
||||
// make a facet unreadable for describing a capability it
|
||||
// also has.
|
||||
try { out.add(Form.fromJson(new String(bytes, StandardCharsets.UTF_8))); }
|
||||
catch (RuntimeException e) { LOG.warning("facet '" + facet.name() + "': form record " + o + " not understood, skipping: " + e.getMessage()); }
|
||||
}
|
||||
if (out.isEmpty()) out.add(Form.implicit());
|
||||
return out;
|
||||
} catch (RecordException e) {
|
||||
throw BindException.facet(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects a form by name.
|
||||
///
|
||||
/// A name this facet does not offer is refused naming what it does —
|
||||
/// the same rule as a wrong-door reader error, for the same reason.
|
||||
/// The implicit form answers to its own name but is not listed as
|
||||
/// a choice, so a caller can tell "undeclared" from "declared and
|
||||
/// unmatched".
|
||||
public static Form byName(RecordFacet facet, String name) {
|
||||
List<Form> forms = of(facet);
|
||||
for (Form form : forms) if (form.name().equals(name)) return form;
|
||||
List<String> available = new ArrayList<>();
|
||||
for (Form form : forms) if (!form.name().equals(Form.IMPLICIT)) available.add(form.name());
|
||||
throw BindException.noSuchForm(name, available);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2026 The NoSQLBench Authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.nosqlbench.vectordata.binding;
|
||||
|
||||
import io.nosqlbench.vectordata.anode.Field;
|
||||
import io.nosqlbench.vectordata.anode.Scan;
|
||||
import io.nosqlbench.vectordata.anode.ScanException;
|
||||
import io.nosqlbench.vectordata.anode.TypeTag;
|
||||
import io.nosqlbench.vectordata.records.RecordException;
|
||||
import io.nosqlbench.vectordata.records.RecordFacet;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/// A facet's field layout: names in wire order, with the type each
|
||||
/// binds as.
|
||||
///
|
||||
/// Learned **once**. The names are the metadata's own, in the order the
|
||||
/// records carry them, and that order is the position a binder
|
||||
/// addresses fields by for the rest of the run.
|
||||
public final class Layout {
|
||||
private final List<String> names;
|
||||
private final List<BindType> types;
|
||||
|
||||
private Layout(List<String> names, List<BindType> types) { this.names = List.copyOf(names); this.types = List.copyOf(types); }
|
||||
|
||||
/// Learns a facet's layout from its first record.
|
||||
///
|
||||
/// A facet publishing a schema namespace should be preferred over
|
||||
/// this, because a first record that omits an optional field would
|
||||
/// otherwise define the layout for every record after it. That
|
||||
/// preference is the caller's to express until a schema-namespace
|
||||
/// reader exists; this is the discovery path.
|
||||
public static Layout discover(RecordFacet facet) {
|
||||
try {
|
||||
if (facet.count() == 0) throw BindException.noLayout(facet.name());
|
||||
return discover(facet.recordBytes(0));
|
||||
} catch (RecordException e) {
|
||||
throw BindException.facet(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/// Learns a layout from one record's bytes — the same walk, for a
|
||||
/// caller holding a sample record rather than a facet.
|
||||
public static Layout discover(byte[] record) {
|
||||
List<String> names = new ArrayList<>();
|
||||
List<BindType> types = new ArrayList<>();
|
||||
try {
|
||||
for (Field field : Scan.fields(record)) {
|
||||
names.add(field.name());
|
||||
TypeTag tag = field.typeTag();
|
||||
types.add(tag == null ? BindType.BLOB : BindType.ofTag(tag));
|
||||
}
|
||||
} catch (ScanException e) {
|
||||
throw BindException.record(e.getMessage());
|
||||
}
|
||||
return new Layout(names, types);
|
||||
}
|
||||
|
||||
/// Field names, in wire order.
|
||||
public List<String> names() { return names; }
|
||||
/// What each field binds as, by position.
|
||||
public List<BindType> types() { return types; }
|
||||
/// The position of a named field, or `-1`.
|
||||
public int positionOf(String field) { return names.indexOf(field); }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2026 The NoSQLBench Authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.nosqlbench.vectordata.binding;
|
||||
|
||||
import io.nosqlbench.vectordata.anode.ANode;
|
||||
import io.nosqlbench.vectordata.anode.ANodeException;
|
||||
import io.nosqlbench.vectordata.anode.PNode;
|
||||
import io.nosqlbench.vectordata.anode.PNode.Comparand;
|
||||
import io.nosqlbench.vectordata.anode.Scan;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/// A compiled predicate template: the shape of a filter, prepared once.
|
||||
///
|
||||
/// Compiled against **two** things — a sample predicate, which supplies
|
||||
/// the fields and operators, and the metadata layout, which supplies
|
||||
/// the types. Neither alone is enough: a predicate knows what it
|
||||
/// constrains and not what type that field is. A filtered workload
|
||||
/// needs a `WHERE` clause with parameters, not a rendered fragment with
|
||||
/// comparands inlined; this is the parameter list.
|
||||
public final class PredicateBinder {
|
||||
private final List<Condition> conditions;
|
||||
/// The shape every bound record must match, values removed.
|
||||
private final PNode shape;
|
||||
|
||||
private PredicateBinder(List<Condition> conditions, PNode shape) { this.conditions = List.copyOf(conditions); this.shape = shape; }
|
||||
|
||||
/// Compiles a predicate template.
|
||||
///
|
||||
/// Only a flat conjunction compiles: a nested or disjunctive
|
||||
/// predicate has no flat parameter list, and pretending otherwise
|
||||
/// would bind values into the wrong conditions. Such a predicate is
|
||||
/// refused by name rather than partially flattened.
|
||||
public static PredicateBinder compile(PNode predicate, Layout layout) {
|
||||
List<Scan.FlatCondition> flat = Scan.flattenAnd(predicate);
|
||||
if (flat == null)
|
||||
throw BindException.record("this predicate is not a flat conjunction of named field comparisons, so it has no parameter list — bind its conditions individually, or use a template that is");
|
||||
List<Condition> conditions = new ArrayList<>(flat.size());
|
||||
for (Scan.FlatCondition c : flat) {
|
||||
int position = layout.positionOf(c.field());
|
||||
if (position < 0) throw BindException.noSuchField(c.field(), layout.names());
|
||||
// The field's type, not the comparand's.
|
||||
conditions.add(new Condition(c.field(), c.field(), c.op(), layout.types().get(position), c.comparands().size()));
|
||||
}
|
||||
return new PredicateBinder(conditions, predicate.fingerprint());
|
||||
}
|
||||
|
||||
/// Renames parameters for substitution, as the metadata binder does.
|
||||
public PredicateBinder withOverrides(Map<String, String> overrides) {
|
||||
List<Condition> renamed = new ArrayList<>(conditions.size());
|
||||
for (Condition c : conditions) renamed.add(new Condition(c.field(), overrides.getOrDefault(c.field(), c.parameter()), c.op(), c.bindType(), c.arity()));
|
||||
return new PredicateBinder(renamed, shape);
|
||||
}
|
||||
|
||||
/// The conditions, in order. Read once, to build the filter.
|
||||
public List<Condition> conditions() { return conditions; }
|
||||
|
||||
/// Receives one condition's comparands from a bound predicate.
|
||||
@FunctionalInterface
|
||||
public interface ConditionSink { void accept(Condition condition, List<Comparand> comparands); }
|
||||
|
||||
/// Binds one predicate record, handing each condition's comparands
|
||||
/// to `sink` in condition order.
|
||||
///
|
||||
/// The record is checked against the compiled shape first: a
|
||||
/// predicate of a different shape would otherwise bind values into
|
||||
/// conditions they do not belong to, which no downstream check
|
||||
/// would catch. A predicate is decoded per record — there is no
|
||||
/// raw scanner for a PNode as there is for an MNode — because a
|
||||
/// predicate is small and the shape check dominates.
|
||||
public void bindEach(byte[] record, ConditionSink sink) {
|
||||
ANode node;
|
||||
try { node = ANode.decode(record); }
|
||||
catch (ANodeException e) { throw BindException.record(e.getMessage()); }
|
||||
// The leader byte is the authority on what a record is; a
|
||||
// template cannot make an MNode into a predicate.
|
||||
if (!(node instanceof ANode.P p))
|
||||
throw BindException.record("this record is not a predicate — its dialect byte says MNode, and a form does not override what a record is");
|
||||
if (!p.node().isCongruent(shape))
|
||||
throw BindException.record("this predicate's shape differs from the one this binder was compiled against; its values would bind to the wrong conditions");
|
||||
List<Scan.FlatCondition> flat = Scan.flattenAnd(p.node());
|
||||
if (flat == null) throw BindException.record("predicate is not a flat conjunction");
|
||||
for (int i = 0; i < conditions.size() && i < flat.size(); i++) sink.accept(conditions.get(i), flat.get(i).comparands());
|
||||
}
|
||||
}
|
||||
+33
@@ -563,4 +563,37 @@ class PrefetchRemoteIntegrationTest {
|
||||
assertTrue(resident > 0 && resident < whole.facetBytes() / 4, "one record fetched " + resident + " of " + whole.facetBytes() + " bytes");
|
||||
assertFalse(whole.isResident());
|
||||
}
|
||||
|
||||
/// A template runtime can compile against a remote dataset: the
|
||||
/// layout is learned from the first record — one page — and the
|
||||
/// binder built from it drives a cycle over records scattered
|
||||
/// through the facet. The schema is remote, the values are remote,
|
||||
/// and the file is never downloaded.
|
||||
@Test void aBinderCompilesAndBindsOverHttp() throws Exception {
|
||||
Path published = Files.createDirectories(temporary.resolve("pub-bind"));
|
||||
List<byte[]> records = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < 4000; i++)
|
||||
records.add(new io.nosqlbench.vectordata.anode.MNode().insert("id", new io.nosqlbench.vectordata.anode.MValue.Int32(i))
|
||||
.insert("bucket", new io.nosqlbench.vectordata.anode.MValue.Int32(i % 4)).toBytes());
|
||||
Path slab = FixtureSupport.slabOf(published, "m.slab", records, 50);
|
||||
Files.write(published.resolve("m.slab.mref"), FixtureSupport.mref(Files.readAllBytes(slab), CHUNK));
|
||||
HttpServer server = serveDirectory(published, new AtomicInteger(), true);
|
||||
String base = "http://127.0.0.1:" + server.getAddress().getPort() + "/";
|
||||
TestDataView view = seriesView("slab-binding", "name: slab\nprofiles:\n default:\n metadata_content: " + base + "m.slab\n", "default");
|
||||
var facet = view.openFacetRecords("metadata_content");
|
||||
var layout = io.nosqlbench.vectordata.binding.Layout.discover(facet);
|
||||
assertEquals(List.of("id", "bucket"), layout.names());
|
||||
assertEquals(List.of(io.nosqlbench.vectordata.binding.BindType.INT32, io.nosqlbench.vectordata.binding.BindType.INT32), layout.types());
|
||||
var binder = io.nosqlbench.vectordata.binding.Binder.select(layout, "bucket", "id");
|
||||
for (long o : new long[] {0, 1, 1500, 3999}) {
|
||||
binder.bindEach(facet.recordBytes(o), (slot, value) -> {
|
||||
if (slot == 0) assertEquals(o % 4, value.longValue(), "bucket at " + o);
|
||||
else assertEquals(o, value.longValue(), "id at " + o);
|
||||
});
|
||||
}
|
||||
PrefetchPlan whole = view.prefetchPlan("metadata_content", DSWindow.ALL);
|
||||
long resident = whole.fills().stream().mapToLong(f -> (long) f.chunksResident() * f.chunkSize()).sum();
|
||||
assertFalse(whole.isResident(), "compiling and binding must not have pulled the facet down");
|
||||
assertTrue(resident < whole.facetBytes() / 4, "bound four records with " + resident + " of " + whole.facetBytes() + " bytes");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
/*
|
||||
* Copyright (c) 2026 The NoSQLBench Authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package io.nosqlbench.vectordata;
|
||||
|
||||
import io.nosqlbench.vectordata.anode.ANode;
|
||||
import io.nosqlbench.vectordata.anode.ANodeException;
|
||||
import io.nosqlbench.vectordata.anode.Field;
|
||||
import io.nosqlbench.vectordata.anode.MNode;
|
||||
import io.nosqlbench.vectordata.anode.MNodeVernacular;
|
||||
import io.nosqlbench.vectordata.anode.MValue;
|
||||
import io.nosqlbench.vectordata.anode.PNode;
|
||||
import io.nosqlbench.vectordata.anode.PNode.Comparand;
|
||||
import io.nosqlbench.vectordata.anode.PNode.ConjugateType;
|
||||
import io.nosqlbench.vectordata.anode.PNode.FieldRef;
|
||||
import io.nosqlbench.vectordata.anode.PNode.OpType;
|
||||
import io.nosqlbench.vectordata.anode.TypeTag;
|
||||
import io.nosqlbench.vectordata.anode.Vernacular;
|
||||
import io.nosqlbench.vectordata.anode.Vernaculars;
|
||||
import io.nosqlbench.vectordata.binding.BindException;
|
||||
import io.nosqlbench.vectordata.binding.BindType;
|
||||
import io.nosqlbench.vectordata.binding.Binder;
|
||||
import io.nosqlbench.vectordata.binding.Condition;
|
||||
import io.nosqlbench.vectordata.binding.Form;
|
||||
import io.nosqlbench.vectordata.binding.Forms;
|
||||
import io.nosqlbench.vectordata.binding.Layout;
|
||||
import io.nosqlbench.vectordata.binding.PredicateBinder;
|
||||
import io.nosqlbench.vectordata.records.RecordFacet;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/// Binding records to operation parameters, numbered as the acceptance
|
||||
/// cases in the reference's record-binding design: a layout learned
|
||||
/// once, binders compiled against it, forms declared or implicit,
|
||||
/// predicates bound as parameters, and the type asymmetries that make
|
||||
/// binding a contract of its own rather than a rendering.
|
||||
@Tag("unit")
|
||||
class RecordBindingTest {
|
||||
@TempDir Path temporary;
|
||||
|
||||
private VectorDataSettings settings() { return VectorDataSettings.builder().cacheDirectory(temporary.resolve("cache")).build(); }
|
||||
|
||||
static byte[] row(long id, String tag, double score) {
|
||||
return new MNode().insert("id", new MValue.Int(id)).insert("tag", new MValue.Text(tag)).insert("score", new MValue.Float(score)).toBytes();
|
||||
}
|
||||
|
||||
static List<byte[]> utf8(String... texts) { List<byte[]> out = new ArrayList<>(); for (String t : texts) out.add(t.getBytes(StandardCharsets.UTF_8)); return out; }
|
||||
|
||||
/// A dataset whose metadata facet holds `records`, with a `forms`
|
||||
/// namespace when `forms` is given.
|
||||
private Path dataset(String name, List<byte[]> records, List<byte[]> forms) throws IOException {
|
||||
Path dir = Files.createDirectories(temporary.resolve(name));
|
||||
if (forms == null) FixtureSupport.slabOf(dir, "metadata_content.slab", records, 4);
|
||||
else FixtureSupport.slabWithNamespaces(dir, "metadata_content.slab", records, Map.of(Forms.NAMESPACE, forms), 4);
|
||||
FixtureSupport.fvec(dir, "base.fvec", new float[][] {{0f, 1f, 2f, 3f}});
|
||||
Files.writeString(dir.resolve("dataset.yaml"), "name: b\nprofiles:\n default:\n base_vectors: base.fvec\n metadata_content: metadata_content.slab\n");
|
||||
return dir;
|
||||
}
|
||||
|
||||
private RecordFacet open(Path dir) { return TestDataGroup.load(dir.toUri(), settings()).profile("default").openFacetRecords("metadata_content"); }
|
||||
|
||||
// ── cases 5, 6: the binding contract ───────────────────────────
|
||||
|
||||
/// **Case 5** — a bound record is names and typed values, in
|
||||
/// declared order. **Case 6** — the layout is obtained once, before
|
||||
/// any record.
|
||||
@Test void aBoundRecordIsNamedTypedValuesInDeclaredOrder() throws IOException {
|
||||
RecordFacet facet = open(dataset("c5", List.of(row(1, "a", 1.5), row(2, "b", 2.5)), null));
|
||||
Layout layout = Layout.discover(facet);
|
||||
assertEquals(List.of("id", "tag", "score"), layout.names());
|
||||
assertEquals(List.of(BindType.INT64, BindType.TEXT, BindType.FLOAT64), layout.types());
|
||||
Binder binder = Binder.all(layout);
|
||||
assertEquals(List.of("id", "tag", "score"), binder.parameters());
|
||||
List<Field> out = binder.bind(facet.recordBytes(1));
|
||||
assertEquals(3, out.size());
|
||||
assertEquals(2L, out.get(0).longValue());
|
||||
assertEquals("b", out.get(1).stringValue());
|
||||
assertEquals(2.5, out.get(2).doubleValue());
|
||||
}
|
||||
|
||||
/// A template may name fields in any order, and bind order follows
|
||||
/// the template rather than the record.
|
||||
@Test void bindOrderFollowsTheTemplateNotTheRecord() throws IOException {
|
||||
RecordFacet facet = open(dataset("order", List.of(row(7, "z", 9.0)), null));
|
||||
Binder binder = Binder.select(Layout.discover(facet), "score", "id");
|
||||
assertEquals(List.of("score", "id"), binder.parameters());
|
||||
List<Field> out = binder.bind(facet.recordBytes(0));
|
||||
assertEquals(9.0, out.get(0).doubleValue());
|
||||
assertEquals(7L, out.get(1).longValue());
|
||||
List<String> seen = new ArrayList<>();
|
||||
binder.bindEach(facet.recordBytes(0), (slot, f) -> seen.add(slot + ":" + f.name()));
|
||||
assertEquals(List.of("1:id", "0:score"), seen, "the loop form meets fields in wire order, tagged with their slot");
|
||||
}
|
||||
|
||||
/// A field the facet does not have is refused when the binder is
|
||||
/// built, naming what the facet does have — not on the first cycle.
|
||||
@Test void anUnknownFieldIsRefusedAtCompileTime() throws IOException {
|
||||
Layout layout = Layout.discover(open(dataset("unknown", List.of(row(1, "a", 1.0)), null)));
|
||||
BindException e = assertThrows(BindException.class, () -> Binder.select(layout, "id", "nope"));
|
||||
assertEquals(BindException.Kind.NO_SUCH_FIELD, e.kind());
|
||||
assertEquals("nope", e.name());
|
||||
assertEquals(List.of("id", "tag", "score"), e.available());
|
||||
assertTrue(e.getMessage().contains("nope") && e.getMessage().contains("id, tag, score"), e.getMessage());
|
||||
}
|
||||
|
||||
/// Names are the metadata names, and a runtime may override one for
|
||||
/// substitution — applied at compile time, leaving the facet's own
|
||||
/// name untouched.
|
||||
@Test void aParameterMayBeRenamedWithoutTouchingTheField() throws IOException {
|
||||
RecordFacet facet = open(dataset("rename", List.of(row(3, "c", 3.5)), null));
|
||||
Layout layout = Layout.discover(facet);
|
||||
Binder binder = Binder.all(layout).withOverrides(Map.of("id", "pk"));
|
||||
assertEquals(List.of("pk", "tag", "score"), binder.parameters());
|
||||
assertEquals("id", layout.names().get(0), "the facet still calls it id");
|
||||
assertEquals(3L, binder.bind(facet.recordBytes(0)).get(0).longValue(), "the rename moved no data");
|
||||
}
|
||||
|
||||
/// A record whose layout differs from the one the binder was
|
||||
/// compiled against is refused, not bound short.
|
||||
@Test void aRecordOfAnotherLayoutIsRefused() throws IOException {
|
||||
Layout layout = Layout.discover(open(dataset("layout", List.of(row(1, "a", 1.0)), null)));
|
||||
Binder binder = Binder.all(layout);
|
||||
byte[] narrower = new MNode().insert("id", new MValue.Int(1)).toBytes();
|
||||
BindException e = assertThrows(BindException.class, () -> binder.bindEach(narrower, (s, f) -> { }));
|
||||
assertEquals(BindException.Kind.RECORD, e.kind());
|
||||
assertTrue(e.getMessage().contains("layout differs"), e.getMessage());
|
||||
assertEquals(BindException.Kind.NO_SUCH_FIELD, assertThrows(BindException.class, () -> binder.bind(narrower)).kind());
|
||||
}
|
||||
|
||||
// ── cases 1–4: forms ───────────────────────────────────────────
|
||||
|
||||
/// **Case 1 / case 10 — the gate.** A facet with no `forms`
|
||||
/// namespace offers exactly one form and binds unchanged. That is
|
||||
/// every dataset in existence; absence is not an empty set.
|
||||
@Test void aFacetWithoutFormsOffersOneImplicitForm() throws IOException {
|
||||
RecordFacet facet = open(dataset("implicit", List.of(row(1, "a", 1.0)), null));
|
||||
List<Form> forms = Forms.of(facet);
|
||||
assertEquals(1, forms.size(), "one implicit form, not none");
|
||||
assertEquals(Form.IMPLICIT, forms.get(0).name());
|
||||
assertEquals(List.of("id", "tag", "score"), forms.get(0).binder(Layout.discover(facet)).parameters());
|
||||
}
|
||||
|
||||
/// **Case 2** — declared forms are enumerable by name, and each
|
||||
/// compiles to its own binder.
|
||||
@Test void declaredFormsAreEnumerableAndCompileIndependently() throws IOException {
|
||||
RecordFacet facet = open(dataset("forms", List.of(row(1, "a", 1.0)), utf8(
|
||||
"{\"name\":\"row\",\"operation\":\"insert\",\"fields\":[\"id\",\"tag\",\"score\"]}",
|
||||
"{\"name\":\"key\",\"operation\":\"get\",\"fields\":[\"id\"],\"parameters\":{\"id\":\"pk\"}}")));
|
||||
Layout layout = Layout.discover(facet);
|
||||
assertEquals(List.of("row", "key"), Forms.of(facet).stream().map(Form::name).toList());
|
||||
Binder rowBinder = Forms.byName(facet, "row").binder(layout);
|
||||
assertEquals(List.of("id", "tag", "score"), rowBinder.parameters());
|
||||
assertEquals("insert", Forms.byName(facet, "row").operation());
|
||||
Binder keyBinder = Forms.byName(facet, "key").binder(layout);
|
||||
assertEquals(List.of("pk"), keyBinder.parameters());
|
||||
assertEquals(List.of(BindType.INT64), keyBinder.types());
|
||||
List<Field> out = keyBinder.bind(facet.recordBytes(0));
|
||||
assertEquals(1, out.size());
|
||||
assertEquals(1L, out.get(0).longValue());
|
||||
}
|
||||
|
||||
/// **Case 3** — a form the facet does not offer is refused, naming
|
||||
/// the ones it does.
|
||||
@Test void anUnknownFormIsRefusedNamingWhatIsOffered() throws IOException {
|
||||
RecordFacet facet = open(dataset("noform", List.of(row(1, "a", 1.0)), utf8("{\"name\":\"row\"}")));
|
||||
BindException e = assertThrows(BindException.class, () -> Forms.byName(facet, "document"));
|
||||
assertEquals(BindException.Kind.NO_SUCH_FORM, e.kind());
|
||||
assertEquals("document", e.name());
|
||||
assertEquals(List.of("row"), e.available());
|
||||
assertTrue(e.getMessage().contains("document") && e.getMessage().contains("row"), e.getMessage());
|
||||
}
|
||||
|
||||
/// **Case 4** — a form carrying keys this build does not know is
|
||||
/// preserved, not rejected; and a form record it cannot read at all
|
||||
/// is skipped, leaving the ones it can.
|
||||
@Test void anUnrecognisedFormKeyIsPreserved() throws IOException {
|
||||
RecordFacet facet = open(dataset("extra", List.of(row(1, "a", 1.0)), utf8(
|
||||
"{\"name\":\"row\",\"consistency\":\"quorum\",\"ttl_seconds\":600}", "not a form at all", "{\"kind\":\"metadata\"}")));
|
||||
Form form = Forms.byName(facet, "row");
|
||||
assertEquals(new MValue.Text("quorum"), form.extra().get("consistency"));
|
||||
assertEquals(new MValue.Int(600), form.extra().get("ttl_seconds"));
|
||||
assertEquals(List.of("id", "tag", "score"), form.binder(Layout.discover(facet)).parameters(), "an unknown key is not a broken form");
|
||||
assertEquals(1, Forms.of(facet).size(), "the two unreadable records were skipped, not fatal");
|
||||
assertThrows(ANodeException.class, () -> Form.fromJson("{\"kind\":\"metadata\"}"), "a form without a name is not a form");
|
||||
}
|
||||
|
||||
// ── cases 13, 14, 16: the type asymmetry ───────────────────────
|
||||
|
||||
/// **Case 13** — a `Half` binds as a float, not as a smallint. The
|
||||
/// rendering mapping still says smallint, and is still right about
|
||||
/// DDL — the two answers coexist rather than one being wrong.
|
||||
@Test void aHalfBindsAsAFloatNotAnInteger() {
|
||||
assertEquals(BindType.FLOAT16, BindType.ofTag(TypeTag.HALF));
|
||||
assertNotEquals(BindType.INT16, BindType.ofTag(TypeTag.HALF));
|
||||
assertTrue(MNodeVernacular.toCqlSchema(new MNode().insert("h", new MValue.Half(0x3C00))).contains("smallint"));
|
||||
}
|
||||
|
||||
/// **Case 14** — a container's element type is not collapsed to
|
||||
/// text; the tag alone does not determine it, and the honest answer
|
||||
/// is to say so.
|
||||
@Test void containerElementTypesAreUndeterminedRatherThanGuessed() {
|
||||
for (TypeTag tag : List.of(TypeTag.LIST, TypeTag.ARRAY)) {
|
||||
assertEquals(BindType.list(null), BindType.ofTag(tag));
|
||||
assertTrue(BindType.ofTag(tag).isUnderdetermined());
|
||||
}
|
||||
assertEquals(BindType.set(null), BindType.ofTag(TypeTag.SET));
|
||||
assertEquals(BindType.map(null, null), BindType.ofTag(TypeTag.MAP));
|
||||
assertTrue(BindType.ofTag(TypeTag.TYPED_MAP).isUnderdetermined());
|
||||
assertFalse(BindType.ofTag(TypeTag.INT).isUnderdetermined());
|
||||
assertFalse(BindType.ofTag(TypeTag.UUID_V7).isUnderdetermined());
|
||||
assertFalse(BindType.list(BindType.TEXT).isUnderdetermined());
|
||||
assertEquals("list<text>", BindType.list(BindType.TEXT).toString());
|
||||
assertEquals("map<?, ?>", BindType.ofTag(TypeTag.MAP).toString());
|
||||
}
|
||||
|
||||
/// **Case 16** — every tag has a bind type; the exhaustive switch is
|
||||
/// the real guarantee, and this pins what it currently answers.
|
||||
@Test void everyTagHasABindType() {
|
||||
assertEquals(29, TypeTag.values().length, "the tag set is a cross-language contract");
|
||||
for (TypeTag tag : TypeTag.values()) assertNotNull(BindType.ofTag(tag));
|
||||
assertEquals(BindType.NULL, BindType.ofTag(TypeTag.NULL), "null carries no type of its own");
|
||||
assertNotEquals(BindType.ofTag(TypeTag.UUID_V1), BindType.ofTag(TypeTag.UUID_V7));
|
||||
assertNotEquals(BindType.ofTag(TypeTag.MILLIS), BindType.ofTag(TypeTag.NANOS));
|
||||
assertEquals(BindType.TIMESTAMP_TEXT, BindType.ofTag(TypeTag.DATETIME), "text on the wire");
|
||||
assertNotEquals(BindType.ofTag(TypeTag.DATETIME), BindType.ofTag(TypeTag.NANOS));
|
||||
}
|
||||
|
||||
// ── cases 11, 12: series and scattered access ──────────────────
|
||||
|
||||
/// **Case 11** — forms and layout are read from the series, not
|
||||
/// from shard 0. A sharded facet's layout is the facet's.
|
||||
@Test void aShardedFacetBindsAcrossItsShards() throws IOException {
|
||||
Path dir = Files.createDirectories(temporary.resolve("sharded"));
|
||||
for (int shard = 0; shard < 2; shard++) {
|
||||
List<byte[]> rows = new ArrayList<>();
|
||||
for (int i = 0; i < 5; i++) rows.add(row(shard * 5L + i, "x", shard * 5 + i));
|
||||
FixtureSupport.slabOf(dir, String.format("meta__%04d.slab", shard), rows, 4);
|
||||
}
|
||||
FixtureSupport.fvec(dir, "base.fvec", new float[][] {{0f, 1f, 2f, 3f}});
|
||||
Files.writeString(dir.resolve("dataset.yaml"), """
|
||||
name: sh
|
||||
profiles:
|
||||
default:
|
||||
base_vectors: base.fvec
|
||||
metadata_content:
|
||||
source: meta__NNNN.slab
|
||||
shard_stride: 5
|
||||
shard_count: 2
|
||||
record_count: 10
|
||||
""");
|
||||
RecordFacet facet = open(dir);
|
||||
assertEquals(10, facet.count());
|
||||
assertEquals(1, Forms.of(facet).size(), "one implicit form for the whole series, not one per shard");
|
||||
Binder binder = Binder.all(Layout.discover(facet));
|
||||
for (long o : new long[] {0, 4, 5, 9}) {
|
||||
long[] id = {-1};
|
||||
binder.bindEach(facet.recordBytes(o), (slot, f) -> { if (slot == 0) id[0] = f.longValue(); });
|
||||
assertEquals(o, id[0], "facet ordinal " + o);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Case 12** — binding stays incremental, addressing scattered
|
||||
/// records by ordinal; the remote half is pinned against a served
|
||||
/// facet in the HTTP integration suite.
|
||||
@Test void bindingAddressesScatteredRecords() throws IOException {
|
||||
List<byte[]> rows = new ArrayList<>();
|
||||
for (int i = 0; i < 2000; i++) rows.add(row(i, "x", i));
|
||||
RecordFacet facet = open(dataset("scattered", rows, null));
|
||||
Binder binder = Binder.select(Layout.discover(facet), "id");
|
||||
for (long o : new long[] {0, 900, 1999}) {
|
||||
long[] id = {-1};
|
||||
binder.bindEach(facet.recordBytes(o), (slot, f) -> id[0] = f.longValue());
|
||||
assertEquals(o, id[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── cases 7, 8, 15: predicate binding ──────────────────────────
|
||||
|
||||
static PNode pred(String field, OpType op, Comparand c) { return new PNode.Predicate(new FieldRef.Named(field), op, List.of(c)); }
|
||||
static PNode and(PNode... children) { return new PNode.Conjugate(ConjugateType.AND, List.of(children)); }
|
||||
|
||||
/// A metadata layout with a temporal and a uuid field, so the typing
|
||||
/// question in case 15 is answerable.
|
||||
private Layout typedLayout(String name) throws IOException {
|
||||
byte[] record = new MNode().insert("id", new MValue.Int(1)).insert("created", new MValue.Millis(1_700_000_000_000L))
|
||||
.insert("owner", new MValue.UuidV7(new byte[16])).insert("tag", new MValue.Text("a")).toBytes();
|
||||
return Layout.discover(open(dataset(name, List.of(record), null)));
|
||||
}
|
||||
|
||||
/// **Case 7** — a predicate binds parameters, not an inlined
|
||||
/// fragment: the statement is fixed and only the values move.
|
||||
@Test void aPredicateBindsParametersRatherThanInliningThem() throws IOException {
|
||||
Layout layout = typedLayout("c7");
|
||||
PNode template = and(pred("created", OpType.GE, new Comparand.Int(0)), pred("tag", OpType.EQ, new Comparand.Text("")));
|
||||
PredicateBinder binder = PredicateBinder.compile(template, layout);
|
||||
List<Condition> c = binder.conditions();
|
||||
assertEquals(2, c.size());
|
||||
assertEquals("created", c.get(0).field()); assertEquals(OpType.GE, c.get(0).op());
|
||||
assertEquals("tag", c.get(1).field()); assertEquals(OpType.EQ, c.get(1).op());
|
||||
assertEquals(1, c.get(0).arity());
|
||||
PNode actual = and(pred("created", OpType.GE, new Comparand.Int(1_700_000_000_000L)), pred("tag", OpType.EQ, new Comparand.Text("blue")));
|
||||
List<String> params = new ArrayList<>();
|
||||
List<Comparand> values = new ArrayList<>();
|
||||
binder.bindEach(actual.toBytesNamed(), (cond, cs) -> { params.add(cond.parameter()); values.add(cs.get(0)); });
|
||||
assertEquals(List.of("created", "tag"), params);
|
||||
assertEquals(new Comparand.Int(1_700_000_000_000L), values.get(0));
|
||||
assertEquals(new Comparand.Text("blue"), values.get(1));
|
||||
assertTrue(Vernaculars.render(ANode.of(actual), Vernacular.CQL).contains("blue"), "rendering inlines");
|
||||
assertEquals(List.of("since", "tag"), binder.withOverrides(Map.of("created", "since")).conditions().stream().map(Condition::parameter).toList());
|
||||
}
|
||||
|
||||
/// **Case 15** — a parameter's type comes from the **field's** tag,
|
||||
/// not the comparand's variant.
|
||||
@Test void aParameterIsTypedFromTheFieldNotTheComparand() throws IOException {
|
||||
Layout layout = typedLayout("c15");
|
||||
PNode template = and(pred("created", OpType.GE, new Comparand.Int(0)), pred("owner", OpType.EQ, new Comparand.Bytes(new byte[16])), pred("id", OpType.EQ, new Comparand.Int(0)));
|
||||
List<Condition> c = PredicateBinder.compile(template, layout).conditions();
|
||||
assertEquals(BindType.TIMESTAMP_MILLIS, c.get(0).bindType(), "not INT64");
|
||||
assertEquals(BindType.UUID, c.get(1).bindType(), "not BLOB");
|
||||
assertEquals(BindType.INT64, c.get(2).bindType());
|
||||
assertNotEquals(BindType.ofTag(TypeTag.INT), c.get(0).bindType());
|
||||
BindException e = assertThrows(BindException.class, () -> PredicateBinder.compile(pred("absent", OpType.EQ, new Comparand.Int(0)), layout));
|
||||
assertEquals(BindException.Kind.NO_SUCH_FIELD, e.kind());
|
||||
}
|
||||
|
||||
/// **Case 8** — the dialect leader byte is the authority. A metadata
|
||||
/// record handed to a predicate binder is refused by what it says
|
||||
/// it is, not accepted because a template expected otherwise.
|
||||
@Test void aFormCannotOverrideTheDialectLeaderByte() throws IOException {
|
||||
PredicateBinder binder = PredicateBinder.compile(and(pred("id", OpType.EQ, new Comparand.Int(0))), typedLayout("c8"));
|
||||
BindException e = assertThrows(BindException.class, () -> binder.bindEach(new MNode().insert("id", new MValue.Int(5)).toBytes(), (c, cs) -> { }));
|
||||
assertTrue(e.getMessage().contains("dialect") && e.getMessage().contains("not a predicate"), e.getMessage());
|
||||
}
|
||||
|
||||
/// A predicate of a different shape is refused rather than bound
|
||||
/// into the wrong conditions — what the congruence check is for.
|
||||
@Test void aPredicateOfAnotherShapeIsRefused() throws IOException {
|
||||
Layout layout = typedLayout("shape");
|
||||
PredicateBinder binder = PredicateBinder.compile(and(pred("created", OpType.GE, new Comparand.Int(0)), pred("tag", OpType.EQ, new Comparand.Text(""))), layout);
|
||||
PNode other = and(pred("created", OpType.LT, new Comparand.Int(1)), pred("tag", OpType.EQ, new Comparand.Text("x")));
|
||||
BindException e = assertThrows(BindException.class, () -> binder.bindEach(other.toBytesNamed(), (c, cs) -> { }));
|
||||
assertTrue(e.getMessage().contains("shape differs"), e.getMessage());
|
||||
}
|
||||
|
||||
/// A predicate that is not a flat conjunction has no parameter
|
||||
/// list, and is refused saying so rather than partially flattened.
|
||||
@Test void aDisjunctivePredicateIsRefusedAtCompileTime() throws IOException {
|
||||
Layout layout = typedLayout("or");
|
||||
PNode or = new PNode.Conjugate(ConjugateType.OR, List.of(pred("id", OpType.EQ, new Comparand.Int(1)), pred("id", OpType.EQ, new Comparand.Int(2))));
|
||||
BindException e = assertThrows(BindException.class, () -> PredicateBinder.compile(or, layout));
|
||||
assertTrue(e.getMessage().contains("flat conjunction"), e.getMessage());
|
||||
}
|
||||
|
||||
// ── error paths and the small surface ──────────────────────────
|
||||
|
||||
/// A facet with no records has no layout to learn, and says so
|
||||
/// rather than producing an empty one that binds nothing.
|
||||
@Test void anEmptyFacetHasNoLayout() throws IOException {
|
||||
RecordFacet facet = open(dataset("empty", List.of(), null));
|
||||
assertEquals(0, facet.count());
|
||||
BindException e = assertThrows(BindException.class, () -> Layout.discover(facet));
|
||||
assertEquals(BindException.Kind.NO_LAYOUT, e.kind());
|
||||
assertTrue(e.name().contains("metadata_content"), e.name());
|
||||
}
|
||||
|
||||
/// A facet with no declared forms reports none *available* while
|
||||
/// still offering its implicit one — the distinction a caller needs
|
||||
/// to tell "undeclared" from "declared and unmatched".
|
||||
@Test void anImplicitFormIsOfferedButNotListedAsAChoice() throws IOException {
|
||||
RecordFacet facet = open(dataset("choice", List.of(row(1, "a", 1.0)), null));
|
||||
assertEquals(1, Forms.of(facet).size());
|
||||
BindException e = assertThrows(BindException.class, () -> Forms.byName(facet, "row"));
|
||||
assertTrue(e.available().isEmpty(), "the implicit form is not a named choice");
|
||||
assertTrue(e.getMessage().contains("only its implicit form"), e.getMessage());
|
||||
assertEquals(Form.IMPLICIT, Forms.byName(facet, Form.IMPLICIT).name(), "but reachable by its own name");
|
||||
}
|
||||
|
||||
/// The layout answers where a field sits, which is the identity
|
||||
/// every compiled binder is built on; and the same walk serves a
|
||||
/// caller holding one record rather than a facet.
|
||||
@Test void aLayoutReportsFieldPositions() throws IOException {
|
||||
Layout layout = Layout.discover(open(dataset("positions", List.of(row(1, "a", 1.0)), null)));
|
||||
assertEquals(0, layout.positionOf("id"));
|
||||
assertEquals(1, layout.positionOf("tag"));
|
||||
assertEquals(2, layout.positionOf("score"));
|
||||
assertEquals(-1, layout.positionOf("absent"));
|
||||
assertEquals(BindType.FLOAT64, layout.types().get(layout.positionOf("score")));
|
||||
assertEquals(layout.names(), Layout.discover(row(9, "q", 0.5)).names());
|
||||
}
|
||||
|
||||
/// The namespace is named by a constant, and the fixtures write the
|
||||
/// name that constant holds.
|
||||
@Test void theFormsNamespaceConstantIsTheNameWritten() throws IOException {
|
||||
assertEquals("forms", Forms.NAMESPACE);
|
||||
RecordFacet facet = open(dataset("constant", List.of(row(1, "a", 1.0)), utf8("{\"name\":\"row\"}")));
|
||||
assertEquals(1, facet.namespace(Forms.NAMESPACE).count());
|
||||
assertEquals("row", Forms.of(facet).get(0).name());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user