Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5b8dd57
feat(workflow-compiling-service): export a workflow as a standalone P…
kz930 Sep 1, 2026
a7558eb
test(workflow-compiling-service): run an operator both ways and compa…
kz930 Sep 1, 2026
cba725f
test(workflow-compiling-service): verify a generated script against t…
kz930 Sep 1, 2026
1cd74b6
feat(workflow-operator): export the base transform operators as Python
kz930 Sep 2, 2026
bc8fc17
ci: give the verify spec a job with an interpreter, and keep it out o…
kz930 Sep 2, 2026
ad5a1e2
Merge remote-tracking branch 'myfork/feat/standalone-verify-harness' …
kz930 Sep 2, 2026
16bb71a
test(verify): pin the no-generator case to an operator that can never…
kz930 Sep 2, 2026
0501644
chore: leave the harness to the change that introduces it
kz930 Sep 2, 2026
fb2fd77
Merge upstream/main
kz930 Sep 2, 2026
c5d3ee6
chore: leave the verification rows to the harness change
kz930 Sep 2, 2026
cb812e5
chore: the schema customizer belongs with the trainers that implement it
kz930 Sep 2, 2026
9bd5e62
docs: say the thing once
kz930 Sep 2, 2026
f2602a4
fix(operator): answer the Regex filter on an empty cell instead of th…
kz930 Sep 3, 2026
c1d0d62
fix(operator): drop the empty cells before the match, not after
kz930 Sep 3, 2026
1d92176
fix(operator): cast to STRING the way the executor does
kz930 Sep 3, 2026
87e6c54
feat(workflow-operator): declare the order flag the sort family overr…
kz930 Sep 4, 2026
8a8a7d0
fix(operator): cast a column the way AttributeTypeUtils does
kz930 Sep 4, 2026
eb2ff3e
Merge remote-tracking branch 'upstream/main' into feat/standalone-bas…
kz930 Sep 4, 2026
c279787
fix(operator): drop the probe key under its post-merge name
kz930 Sep 9, 2026
1270a31
fix(operator): read the interval join keys off the merged frame
kz930 Sep 10, 2026
f85b0f0
fix(operator): cast to string by the column's type, not the value's s…
kz930 Sep 10, 2026
20e19e0
fix(operator): answer as the engine does when a column becomes text
kz930 Sep 10, 2026
e38e697
refactor: leave this change the operators that work on text
kz930 Sep 11, 2026
c9119b7
test(verify): declare the annotation these operators carry
kz930 Sep 11, 2026
611cc6c
refactor: leave the order flag to the change that overrides it
kz930 Sep 11, 2026
3eaba27
Merge remote-tracking branch 'upstream/main' into HEAD
kz930 Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.texera.amber.operator

/** Python definitions shared by several operators' standalone code, emitted
* once per script via [[StandaloneCodeGenerator.standaloneHelpers]].
*/
object StandaloneHelpers {

/**
* A Python transcription of `AttributeTypeUtils`, for operators that cast a
* column to a declared type.
*
* Python's own conversions answer differently on the values a spreadsheet
* column actually holds. `bool("false")` is true, because every non-empty
* string is, and `int("6.7")` raises where the engine reads 6: a cast goes
* through `parseField(force = true)`, whose numeric branch is
* `java.text.NumberFormat`, which truncates a decimal, drops a grouping
* comma and stops at trailing letters. The engine reads "false" as false and
* "0" as false too, so the script has to do the same rather than hand back a
* column the workflow never produced.
*
* Refusing is still part of the contract where the engine refuses: text with
* no leading digits raises, and a script that quietly wrote NaN instead
* would report an answer the run it was exported from never reached.
*/
val AttributeCasts: String =
"""# AttributeTypeUtils, transcribed so a cast answers as the engine does.
|def _texera_cast_boolean(x):
| # toBoolean first, then `toInt == 1`: "0" and "2" are both false.
| if isinstance(x, str):
| text = x.strip()
| lowered = text.lower()
| if lowered == "true":
| return True
| if lowered == "false":
| return False
| return int(text) == 1
| return x != 0
|
|
|def _texera_parse_number(text):
| # java.text.NumberFormat for Locale.US, which a cast reaches through
| # `parseField(force = true)`. Lenient where Python's int is not: it stops
| # at the first character that cannot continue a number ("12abc" is 12),
| # drops "," without checking group sizes ("1,23" is 123), and reads "."
| # as a decimal point. It refuses a leading "+" and text with no digits.
| #
| # Also returns whether it came back as a Double, which decides between
| # the two narrowings below.
| import re
|
| match = re.match(r"\s*(-?)([0-9,]*)(?:\.([0-9]*))?", text)
| sign = -1 if match.group(1) == "-" else 1
| digits = (match.group(2) or "").replace(",", "")
| fraction = match.group(3) or ""
| if not digits and not fraction:
| raise ValueError("Unparseable number: " + repr(text))
| if fraction:
| return sign * float((digits or "0") + "." + fraction), True
| value = sign * int(digits)
| # A whole number past long's range comes back as a Double.
| if -(2 ** 63) <= value <= 2 ** 63 - 1:
| return value, False
| return float(value), True
|
|
|def _texera_long_value(value, is_double):
| # Number.longValue(): a Double truncates toward zero and saturates at
| # long's bounds, where a Long is already itself.
| if is_double:
| return max(-(2 ** 63), min(2 ** 63 - 1, int(value)))
| return value
|
|
|def _texera_cast_integral(x):
| # The LONG target. Scala's toLong takes no decimal point of its own, so
| # a fraction only ever arrives already parsed.
| if isinstance(x, str):
| return _texera_long_value(*_texera_parse_number(x))
| if isinstance(x, bool):
| return 1 if x else 0
| if isinstance(x, float):
| return _texera_long_value(x, True)
| return int(x)
|
|
|def _texera_cast_int32(x, wrap):
| # The two 32-bit narrowings differ: Long.toInt keeps the low bits, so
| # 2147483648 comes back as -2147483648, while Double.toInt saturates.
| #
| # Text decides by what the parse returned; everything else is told by the
| # SOURCE column's declared type, because `Series.apply` hands the cells of
| # a nullable integer column over as floats.
| if isinstance(x, str):
| value, is_double = _texera_parse_number(x)
| wrap = not is_double
| elif isinstance(x, bool):
| value = 1 if x else 0
| else:
| value = int(x)
| if wrap:
| return ((value + 2147483648) % 4294967296) - 2147483648
| # int() first: Double.intValue truncates toward zero before it clamps.
| return max(-2147483648, min(2147483647, int(value)))
|
|
|def _texera_epoch_millis_to_timestamp(s):
| # `new Timestamp(long)` reads MILLISECONDS where pd.to_datetime defaults
| # to nanoseconds, and renders in the JVM's default zone, so leaving the
| # result in UTC would put it a whole offset away. Text needs neither: a
| # parsed wall clock is already the wall clock.
| # tzlocal() and not the current offset: the zone carries its daylight
| # rules, and each instant needs the offset in force when it happened.
| from dateutil.tz import tzlocal
|
| return (
| pd.to_datetime(s, unit="ms", errors="coerce", utc=True)
| .dt.tz_convert(tzlocal())
| .dt.tz_localize(None)
| )
|
|
|def _texera_cast_double(x):
| if isinstance(x, str):
| return float(x.strip())
| return float(x)
|
|
|def _texera_cast_string(s):
| # `toString` on the field, so the COLUMN's type decides the text and
| # not the shape of the value: a double keeps its point, whether or not
| # it lands on a whole number, and an integer never grows one. A column
| # of no single type is read value by value.
| if pd.api.types.is_bool_dtype(s):
| return s.map(lambda x: None if pd.isna(x) else ("true" if x else "false"))
| if pd.api.types.is_integer_dtype(s):
Comment thread
kz930 marked this conversation as resolved.
| return s.map(lambda x: None if pd.isna(x) else str(int(x)))
| if pd.api.types.is_datetime64_any_dtype(s):
| # java.sql.Timestamp.toString: trailing zeros dropped from the
| # fraction, but never all of them. Python's own str() writes no
| # fraction at all on a whole second and six digits otherwise.
| def _ts(x):
| if pd.isna(x):
| return None
| text = x.strftime("%Y-%m-%d %H:%M:%S.%f").rstrip("0")
| return text + "0" if text.endswith(".") else text
|
| return s.map(_ts)
| return s.map(
| lambda x: None
| if pd.isna(x)
| else ("true" if x else "false") if pd.api.types.is_bool(x) else str(x)
| )""".stripMargin
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,31 @@ import org.apache.texera.amber.core.workflow.{
PhysicalOp,
SchemaPropagationFunc
}
import org.apache.texera.amber.operator.StandaloneCodeGenerator
import org.apache.texera.amber.operator.map.MapOpDesc
import org.apache.texera.amber.operator.metadata.annotations.AutofillAttributeName
import org.apache.texera.amber.operator.metadata.annotations.{AutofillAttributeName, SampleColumn}
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral
import org.apache.texera.amber.util.JSONUtils.objectMapper

/**
* Dictionary matcher operator matches a tuple if the specified column is in the given dictionary.
* It outputs an extra column to label the tuple if it is matched or not
* This is the description of the operator
*/
class DictionaryMatcherOpDesc extends MapOpDesc {
class DictionaryMatcherOpDesc extends MapOpDesc with StandaloneCodeGenerator {
@JsonProperty(value = "Dictionary", required = true)
@JsonPropertyDescription("dictionary values separated by a comma") var dictionary: String = _

// Verification matches against a text column holding the value it fills a dictionary
// with, in rows that are single words a stemmer leaves alone -- the conjunction
// branch runs Lucene's stemmer on one side and nothing on the other, so a word that
// stems agrees either way.
@JsonProperty(value = "Attribute", required = true)
@JsonPropertyDescription("column name to match")
@AutofillAttributeName var attribute: String = _
@AutofillAttributeName
@SampleColumn("name")
var attribute: String = _

@JsonProperty(value = "result attribute", required = true, defaultValue = "matched")
@JsonPropertyDescription("column name of the matching result") var resultAttribute: String = _
Expand Down Expand Up @@ -89,4 +97,127 @@ class DictionaryMatcherOpDesc extends MapOpDesc {
outputPorts = List(OutputPort()),
supportReconfiguration = true
)

override def generateStandaloneCode(): String = {
// JVM splits the dictionary on "," and lowercases each entry — no trim,
// so leading/trailing whitespace around a comma becomes part of the entry.
val rawDict = Option(dictionary).getOrElse("")
val entries = rawDict.split(",").toList.map(_.toLowerCase)
val resultCol = Option(resultAttribute)
.filter(_.trim.nonEmpty)
.getOrElse("matched")
val attrPy = pyStringLiteral(Option(attribute).getOrElse(""))
val resultPy = pyStringLiteral(resultCol)
val mt = Option(matchingType).getOrElse(MatchingType.SCANBASED)

val entriesLiteral = entries.map(pyStringLiteral).mkString("[", ", ", "]")

mt match {
case MatchingType.SCANBASED =>
// Exact case-insensitive equality between the (lowercased) cell value
// and any dictionary entry. Null or empty text never matches, matching
// JVM behavior.
s"""out1df = in1df.copy()
|out1df[$resultPy] = out1df[$attrPy].apply(
| lambda v, _entries=$entriesLiteral: (
| False if pd.isna(v)
| else (str(v).lower() != "" and str(v).lower() in _entries)
| )
|)""".stripMargin

case MatchingType.SUBSTRING =>
// JVM checks dictionaryEntries.exists(entry => entry.contains(text)) —
// the cell value (lowercased) is a substring of some dictionary entry,
// NOT the other way around.
s"""out1df = in1df.copy()
|out1df[$resultPy] = out1df[$attrPy].apply(
| lambda v, _entries=$entriesLiteral: (
| False if pd.isna(v)
| else (str(v).lower() != "" and any(str(v).lower() in _e for _e in _entries))
| )
|)""".stripMargin

case MatchingType.CONJUNCTION_INDEXBASED =>
// JVM tokenizes via Lucene EnglishAnalyzer (StandardTokenizer + lowercase
// + English stop words + possessive filter + Porter2 stemmer) and matches
// when an entry's token set is a subset of the text's token set. Best-
// effort reproduction: regex \w+ tokens, lowercase, drop the same stop
// word lists, but NO stemming — morphological variants ("book" vs "books")
// will diverge from JVM behavior.
val tokenSetsLiteral = entries
.map(tokenizeForConjunction)
.map(toks => toks.toList.sorted.map(pyStringLiteral).mkString("frozenset({", ", ", "})"))
.mkString("[", ", ", "]")
val stopWordsLiteral = DictionaryMatcherOpDesc.STOP_WORDS.toList.sorted
.map(pyStringLiteral)
.mkString("frozenset({", ", ", "})")
s"""import re as _texera_dm_re
|_TEXERA_DM_STOPWORDS = $stopWordsLiteral
|def _texera_dm_tokenize(text):
| return frozenset(t for t in _texera_dm_re.findall(r"\\w+", text.lower()) if t not in _TEXERA_DM_STOPWORDS)
|out1df = in1df.copy()
|out1df[$resultPy] = out1df[$attrPy].apply(
| lambda v, _entries=$tokenSetsLiteral: (
| False if pd.isna(v)
| else (lambda _t: bool(_t) and any(_e.issubset(_t) for _e in _entries))(_texera_dm_tokenize(str(v)))
| )
|)""".stripMargin
}
}

private def tokenizeForConjunction(text: String): Set[String] = {
val wordRe = "\\w+".r
wordRe
.findAllIn(text.toLowerCase)
.toSet
.filterNot(DictionaryMatcherOpDesc.STOP_WORDS.contains)
}
}

private object DictionaryMatcherOpDesc {
// Mirrors Lucene's EnglishAnalyzer.ENGLISH_STOP_WORDS_SET plus the URL stop
// words filtered by DictionaryMatcherOpExec.
val STOP_WORDS: Set[String] = Set(
"a",
"an",
"and",
"are",
"as",
"at",
"be",
"but",
"by",
"for",
"if",
"in",
"into",
"is",
"it",
"no",
"not",
"of",
"on",
"or",
"such",
"that",
"the",
"their",
"then",
"there",
"these",
"they",
"this",
"to",
"was",
"will",
"with",
"http",
"https",
"org",
"net",
"com",
"store",
"www",
"html"
)
}
Loading
Loading